[{"content":"这是系列文章的第三篇，也是最后一篇。\nTimely Dataflow：用一个支持有环图的数据流模型，统一 batch、streaming 和 iterative 三种计算范式 Differential Dataflow：如何在 timely dataflow 之上实现通用的增量计算 Materialize（本篇）：如何用 dataflow 引擎构建一个实时 SQL 数据库 为什么需要 Materialize 前两篇文章介绍了两个强大的基础设施：timely dataflow 提供精确的进度追踪，differential dataflow 提供通用的增量计算。但它们都是 Rust 库——使用者需要用 Rust 编写 dataflow 程序，手动定义算子、管理 arrangement、处理输入输出。\n这对大多数数据工程师和分析师来说门槛太高了。人们更熟悉的是 SQL：写一条 SELECT ... FROM ... WHERE ... GROUP BY ...，数据库帮你搞定一切。\n同时，现有的技术方案在\u0026quot;实时物化视图\u0026quot;这个需求上都有明显的短板：\n传统数据库的物化视图：PostgreSQL、Oracle 都支持 CREATE MATERIALIZED VIEW，但更新通常需要手动触发 REFRESH MATERIALIZED VIEW，每次刷新都是全量重算。即使有些系统支持增量刷新（如 Oracle 的 FAST REFRESH），也只覆盖有限的查询类型。\n流处理引擎（Flink、ksqlDB）：能做流式计算，但它们不是数据库。没有标准的 SQL 语义（比如 Flink SQL 的语义和传统 SQL 有微妙差异），没有事务隔离级别，没有一致性快照查询。你不能像用 PostgreSQL 一样用它们。\nMaterialize 的定位是：一个用标准 SQL 接口操作的数据库，其物化视图在数据源变更时自动、实时、增量地更新。 用户写 CREATE MATERIALIZED VIEW，Materialize 将这条 SQL 编译为一个 differential dataflow 程序，持续运行，当上游数据变化时自动更新视图内容。查询物化视图就像查询普通表一样——SELECT * FROM my_view 永远返回最新结果。\n架构总览 Materialize 的整体架构分为三个层次：\npgwire 协议层：兼容 PostgreSQL 客户端协议 Adapter 层（协调器）：SQL 解析与编译、时间戳分配、dataflow 管理 Compute 层：运行 timely + differential dataflow Storage 层：数据摄入与持久化 pgwire 协议 Materialize 实现了 PostgreSQL 的网络协议（pgwire）。你可以用 psql 命令行、JDBC 驱动、任何 PostgreSQL 客户端库直接连接 Materialize，不需要学习新的客户端工具或协议。从客户端的视角来看，Materialize 就是一个 PostgreSQL 数据库。\n这个设计选择降低了用户的迁移成本——现有的应用代码、BI 工具、监控面板，只需要改一下连接字符串就能接入 Materialize。\nAdapter 层（协调器） 协调器是整个系统的\u0026quot;大脑\u0026quot;，承担以下职责：\nSQL 解析与编译：接收 SQL 语句，经过 解析 → AST → HIR → MIR → LIR → Dataflow 的完整编译pipeline（后面详细介绍）。\n时间戳分配：这是保证一致性的核心。每个读查询和写操作都会被分配一个逻辑时间戳。协调器需要确保：分配给读查询的时间戳对应的所有变更都已经在 Compute 层处理完毕。这个机制后面会详细讨论。\nDataflow 管理：协调器知道系统中运行了哪些 dataflow（对应哪些物化视图和索引），管理它们的创建、删除和 arrangement 共享。\nStorage 层 Storage 层负责从外部数据源持续摄入数据。支持的数据源包括：\nKafka：消费 Kafka topic，支持 Avro、Protobuf、JSON 等格式 PostgreSQL CDC：通过逻辑复制（logical replication）持续捕获 PostgreSQL 的变更 Webhook：接收 HTTP 推送的数据 Storage 层将外部数据源的变更转换为 differential dataflow 的 $(data, time, diff)$ 三元组。对于 Kafka，每条消息对应一条 $(data, t, +1)$ 插入；对于 PostgreSQL CDC，INSERT 对应 $(data, t, +1)$，DELETE 对应 $(data, t, -1)$，UPDATE 对应一条删除加一条插入。\n一个关键问题是：外部数据源的时间如何映射到 Materialize 的逻辑时间戳？ Kafka 消息有 offset，PostgreSQL CDC 有 LSN（Log Sequence Number）。Storage 层需要将这些外部时间戳映射到 Materialize 的全局逻辑时间线上，同时维护一个 frontier——告诉 Compute 层\u0026quot;所有小于这个时间戳的数据都已经摄入完毕\u0026quot;。这个 frontier 就是 timely dataflow 进度追踪的输入端。\nCompute 层 Compute 层运行 timely dataflow 和 differential dataflow 的 worker。所有的物化视图、索引、订阅都在这里以 dataflow 图的形式持续运行。\nCompute 层的 worker 可以水平扩展——多个 worker 并行处理不同的数据分片（按 key 哈希分区）。Worker 之间通过 timely dataflow 的通信基础设施交换数据和进度信息。\nSQL 如何变成 Dataflow 一条 SQL 语句到最终的 dataflow 图，经历以下编译阶段：\nSQL → AST → HIR → MIR → LIR → Dataflow 图\nAST：解析阶段，将 SQL 文本转为抽象语法树 HIR：语义分析与高层中间表示 MIR：关系代数表达式 + 优化 LIR：物理计划选择 Dataflow 图：渲染为 timely/differential 算子 这个pipeline和传统数据库的查询编译过程很像——从面向用户的 SQL 逐步降低（lowering）到面向机器的执行计划。区别在于最后一步：传统数据库生成一棵算子树供执行器执行一次后销毁，Materialize 生成的是一个持续运行的增量计算图。\nMIR：关系代数表达式 编译pipeline的核心中间表示是 MIR（Mid-level Intermediate Representation）。一条 SQL 查询在这一层被表达为关系代数表达式。\nMIR 定义了以下核心算子：\nMIR 算子 含义 SQL 对应 Get 引用一个已存在的 collection FROM table Filter 过滤记录 WHERE ... Map 计算新列 SELECT expr AS col Project 选择列子集 SELECT col1, col2 Join 连接多个 collection JOIN ... ON ... Reduce 按 key 聚合 GROUP BY ... + 聚合函数 TopK 取前 N 条 ORDER BY ... LIMIT N Threshold 去重（保留 diff \u0026gt; 0 的记录） DISTINCT Union 合并 UNION ALL Negate 取反（翻转所有 diff） 用于实现 EXCEPT Let / LetRec 局部/递归绑定 CTE (WITH ...) 这些算子和 differential dataflow 的算子几乎一一对应——MIR 的每一个节点都可以直接映射到一个或一组 differential dataflow 算子。这不是巧合，整个 MIR 的设计就是为了让编译的最后一步（渲染为 dataflow）尽可能直接。\n一个简单的例子，SQL 查询：\n1 2 3 4 SELECT region, count(*) FROM orders WHERE amount \u0026gt; 100 GROUP BY region 编译为 MIR（伪代码）：\n1 2 3 Reduce[key=region, agg=count(*)] Filter[amount \u0026gt; 100] Get[orders] 一个更复杂的例子——多表 join 加聚合：\n1 2 3 4 5 6 SELECT d.name, count(o.id), sum(o.amount) FROM orders o JOIN customers c ON o.customer_id = c.id JOIN departments d ON c.dept_id = d.id WHERE o.status = \u0026#39;completed\u0026#39; GROUP BY d.name 编译为 MIR：\n1 2 3 4 5 6 7 Reduce[key=d.name, agg=[count(o.id), sum(o.amount)]] Project[d.name, o.id, o.amount] Join[o.customer_id = c.id AND c.dept_id = d.id] Filter[o.status = \u0026#39;completed\u0026#39;] Get[orders] Get[customers] Get[departments] 注意 MIR 中的 Join 是一个多路 join——它同时接受三个输入（orders、customers、departments），而不是拆成两个二路 join。具体如何拆分是后面物理计划阶段的事。\nMIR 优化 在转换为物理计划之前，MIR 会经过一系列优化变换。这些变换和传统数据库的查询优化类似：\n谓词下推（Predicate Pushdown）：将 Filter 尽可能推到 Join 之前，减少参与 join 的数据量。上面例子中的 o.status = 'completed' 已经在 Join 之前了，但如果用户写成 WHERE d.name = 'Engineering' AND o.status = 'completed'，优化器会把 d.name = 'Engineering' 也推到 Join 之前，直接过滤 departments 表。\n投影下推（Projection Pushdown）：尽早丢弃不需要的列。如果 orders 表有 20 个列，但查询只用到了 id、amount、status、customer_id，优化器会在 Get 之后立即丢弃其他 16 个列，减少后续算子的数据搬运量。\nJoin 顺序优化：对于多路 join，不同的执行顺序性能差异巨大。假如 orders 有 1 亿行，customers 有 100 万行，departments 有 100 行。先 join orders 和 departments 的中间结果可能非常大，但先 join customers 和 departments（结果最多 100 万行），再和 orders join，中间结果小得多。\n公共子表达式消除：如果多个物化视图引用了相同的子查询，优化器会识别出来，在 dataflow 层面共享计算。\n单调性分析（Monotonicity Analysis）：检测输入是否是 append-only 的（只有插入，没有删除和修改）。如果是，某些算子可以用更简单的实现——比如 append-only 的 count 只需要一个计数器，不需要维护完整的 arrangement。\n从 MIR 到 LIR：物理计划 LIR（Low-level Intermediate Representation）是物理执行计划。MIR 描述的是\u0026quot;要做什么\u0026quot;（关系代数），LIR 描述的是\u0026quot;怎么做\u0026quot;（具体的执行策略）。关键的物理计划选择集中在 Join 和 Reduce 两个算子上。\nJoin 策略：Linear Join vs Delta Join 这是 Materialize 中最重要的物理计划选择。\nLinear Join：将 N 路 join 分解为 N-1 个二路 join 的级联。每一步 join 的一侧是上一步的输出（变更流），另一侧是一个 arrangement（索引）。\n以上面的三路 join 为例，linear join 分为两级：\n1 2 Stage 1: orders ⋈ customers → 中间结果 AB Stage 2: AB ⋈ departments → 最终结果 每一级是一个二路 join，两侧各有一个 arrangement。变更的传播取决于哪一侧发生了变化：\norders 变更时：Stage 1 用 Δorders 探测 customers 的 arrangement，输出 ΔAB；ΔAB 流入 Stage 2，探测 departments 的 arrangement，输出最终增量。变更沿pipeline流过，不需要中间结果的 arrangement。 customers 变更时：Stage 1 用 Δcustomers 探测 orders 的 arrangement，输出 ΔAB；ΔAB 同样流入 Stage 2。也不需要中间结果的 arrangement。 departments 变更时：Δdepartments 到达 Stage 2。Stage 2 需要知道\u0026quot;哪些中间结果和这个 department 匹配\u0026quot;——这就需要探测 AB 的 arrangement。系统必须维护中间结果 orders ⋈ customers 的 arrangement。 中间结果可能非常大（orders 有 1 亿行，每条都关联一个 customer），维护它的 arrangement 内存开销显著。而且每当 orders 或 customers 任何一方变化，中间结果的 arrangement 都需要更新。\nDelta Join：为每个输入源各建一条处理pipeline，不维护任何中间结果。\n同样的三路 join，delta join 会生成三条pipeline：\n1 2 3 当 orders 变更时: Δorders → 探测 customers 的 arrangement → 探测 departments 的 arrangement → 输出 当 customers 变更时: Δcustomers → 探测 orders 的 arrangement → 探测 departments 的 arrangement → 输出 当 departments 变更时: Δdepartments → 探测 orders 的 arrangement → 探测 customers 的 arrangement → 输出 每条pipeline只探测已有的基表 arrangement，不需要创建或维护中间结果的 arrangement。上一篇文章详细讨论了 delta join 的原理——它依赖 arrangement 的跨算子共享和多版本查询能力。\n选择策略：Materialize 在大多数场景下倾向使用 delta join，因为它避免了中间结果的内存开销。Linear join 主要用于 delta join 无法适用的场景，比如输入不是持久化的 arrangement（而是一个临时的变更流）。\nReduce 策略 不同的聚合函数有不同的增量化效率。Materialize 将聚合函数分为三类，每类用不同的物理实现：\nAccumulable（sum、count）：这些聚合满足结合律和交换律，可以直接通过累加增量来维护。输入变更 $(key, value, t, +1)$ 时 sum 加上 value，输入 $(key, value, t, -1)$ 时 sum 减去 value。不需要存储每个 key 的完整输入集合，只需要一个累加器。内存开销极低。\nHierarchical（min、max、top-k）：不能简单累加。考虑 min：如果当前最小值被删除了，你需要知道第二小的值是什么——这要求你存储了所有的值。Materialize 用分层结构（类似锦标赛树 / tournament tree）处理：将输入分成多层 bucket，每层维护局部聚合结果，变更从底层向上传播。比起全量重算，只有变更路径上的节点需要更新。\nBasic（jsonb_agg、array_agg、string_agg 等）：无法有效增量化的聚合。当某个 key 的输入变化时，需要从 arrangement 中获取该 key 的完整输入集合，重新计算聚合结果，输出新旧结果的差异。这是 differential dataflow 中 reduce 的通用策略——\u0026ldquo;重算再做差\u0026rdquo;。\n如果一个 GROUP BY 查询同时包含多种类型的聚合（例如 SELECT key, sum(a), max(b), array_agg(c) GROUP BY key），Materialize 会为每种类型的聚合分别选择最优策略，而不是统一用最保守的 Basic 策略。\n从 LIR 到 Dataflow：渲染 最后一步是将 LIR 渲染（render）为实际的 timely/differential dataflow 算子。这一步发生在 Compute 层的 worker 上。\n渲染过程递归地遍历 LIR 树，将每个节点转换为对应的 dataflow 算子：\nGet → 连接到已有的 collection 或 arrangement Filter → differential 的 filter() 算子 Map / Project → differential 的 map() 算子 Join → 根据策略，渲染为 linear join 或 delta join 的算子组合 Reduce → 根据聚合类型，渲染为 accumulable / hierarchical / basic 的具体实现 Arrange → 创建新的 arrangement，注册到全局的 arrangement manager，供其他 dataflow 共享 渲染的结果是一个活跃的 dataflow 图。这个图持续运行：当 Storage 层推入新的变更数据时，变更沿着 dataflow 图传播，最终更新物化视图的 arrangement。\n关键设计决策 时间戳与一致性 Materialize 使用 timely dataflow 的时间戳来保证严格可串行化（strict serializability）。这是最强的一致性级别——每个查询都看到一个一致的快照，且这个快照反映了查询开始之前所有已提交的写操作。\n时间戳如何分配 协调器维护一个全局递增的逻辑时间戳。每个操作被分配一个时间戳：\n写操作（外部数据源的变更进入系统）：由 Storage 层分配时间戳。变更按照数据源的顺序被赋予递增的逻辑时间戳。 读查询（SELECT * FROM my_view）：协调器选择一个时间戳 $T_{read}$，这个 $T_{read}$ 必须满足两个条件： $T_{read}$ 对应的所有变更已经在 Compute 层处理完毕——即 Compute 层的 frontier 已经推进到 $T_{read}$ 之后 $T_{read}$ 足够新，能反映最近的写操作 当 Compute 层的 frontier 还没有推进到 $T_{read}$ 时，读查询会等待，直到 frontier 推进。这就是 timely dataflow 的进度追踪在数据库层面的直接体现——进度追踪不再只是内部优化，而是用户可见的一致性保证。\n为什么不会看到\u0026quot;半更新\u0026quot; 考虑一个物化视图 SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id。假设在时间 $t_5$，users 表中 Alice 的名字被改为 Alicia，同时 orders 表中 Alice 的订单金额被更新。\n如果系统在 users 更新完但 orders 还没更新时返回查询结果，用户会看到不一致的状态——名字已经变了但金额还没变。\nMaterialize 通过时间戳避免了这种情况。协调器分配给读查询的时间戳 $T_{read}$ 必须等到 Compute 层的 frontier 推进到 $T_{read}$ 之后才能返回。而 frontier 推进意味着 $T_{read}$ 之前所有源的所有变更都已经处理完毕——包括 users 和 orders 两侧的变更。查询要么看到两侧都更新前的状态，要么看到两侧都更新后的状态，不会看到中间状态。\nArrangement 管理 索引即 Arrangement 在传统数据库中，索引（CREATE INDEX）和查询执行是两个独立的概念——优化器可能选择使用索引，也可能不用。但在 Materialize 中，索引就是 arrangement。\n1 CREATE INDEX idx_customers_id ON customers (id); 这条语句创建一个以 id 为 key 的 arrangement。这个 arrangement 可以被多个物化视图的 join 算子共享——任何需要按 id 查找 customers 数据的 dataflow 都可以直接读取这个 arrangement，而不需要各自维护一份副本。\n共享的影响 Arrangement 共享是 Materialize 的核心优势之一，但也是最重要的资源管理挑战。\n考虑这个场景：数据库中有一张 orders 表，上面建了 10 个物化视图。其中 6 个按 customer_id 做 join，3 个按 region 做 join，1 个按 product_id 做 join。Materialize 只需要维护 3 个 arrangement（按 customer_id、region、product_id 分别索引），而不是 10 个。当 orders 有更新时，每个 arrangement 只更新一次，所有共享它的物化视图都能看到最新数据。\n但 arrangement 也是 Materialize 最主要的内存消耗来源。每个 arrangement 存储了对应 collection 的完整历史变更（compaction frontier 之后的部分）。如果一张表有 1 GB 数据，为它建了 3 个不同 key 的 arrangement，就需要约 3 GB 内存（加上索引结构的开销）。用户需要在\u0026quot;更多的 arrangement = 更多的查询可以高效执行\u0026quot;和\u0026quot;更多的 arrangement = 更多的内存消耗\u0026quot;之间做权衡。\n错误处理：并行的 oks/errs 流 SQL 执行中会遇到各种运行时错误：除以零、类型转换失败、溢出等。在传统数据库中，这些错误会中止查询。但在 Materialize 中，dataflow 是持续运行的——你不能因为一条数据的除以零就停掉整个物化视图。\nMaterialize 的解决方案是并行错误流。每个 dataflow 算子同时产生两个输出：\noks 流：成功处理的记录 errs 流：遇到错误的记录（记录错误信息和导致错误的数据） 这两个流在 dataflow 中并行传播。错误被当作一种特殊的数据，有自己的 $(error, time, diff)$ 三元组。如果一条导致除以零的记录后来被撤回（比如用户修正了数据），错误也会被自动撤回（$diff = -1$）。\n查询物化视图时，如果 errs 流中有内容，Materialize 会返回错误信息而不是结果。当导致错误的数据被修正后，errs 流为空，查询正常返回。整个过程对用户是透明的——\u0026ldquo;错误\u0026quot;和\u0026quot;数据\u0026quot;一样是可增量维护的。\nSUBSCRIBE：流式输出 除了传统的 SELECT 查询，Materialize 提供了一个独特的功能：SUBSCRIBE。\n1 SUBSCRIBE (TABLE regional_revenue); 这条命令会持续输出物化视图的变更流——每当 regional_revenue 的内容发生变化，SUBSCRIBE 会推送一条变更记录给客户端，包含变更的时间戳、变更的内容和 diff（插入还是删除）。\n这实际上是把 differential dataflow 的 $(data, time, diff)$ 变更流直接暴露给了用户。它使得 Materialize 可以用作实时事件源——下游系统可以订阅物化视图的变更，实时响应数据变化，而不需要轮询。\nCompaction：管理历史数据 Differential dataflow 的 arrangement 会保留数据的完整历史变更。随着时间推移，这些历史数据会消耗大量内存。\nMaterialize 通过 logical compaction 来管理：设定一个 compaction frontier，将 frontier 之前的所有变更合并为一个快照。Frontier 之前的历史细节被丢弃，但当前状态的正确性不受影响。\nCompaction 与一致性的交互需要小心处理。如果一个读查询被分配了时间戳 $T_{read}$，但 compaction frontier 已经推进到 $T_{read}$ 之后（历史已被丢弃），这个查询就无法执行——因为系统已经无法重建 $T_{read}$ 时刻的快照了。Materialize 的协调器在分配时间戳时会考虑 compaction frontier，确保分配的时间戳不会落在已被 compaction 的范围内。\n容错：持久化数据 vs 持久化索引 Timely dataflow 本身没有内置的容错机制。Frank McSherry 在 GitHub issue 中明确说过：timely dataflow 提供的是进度追踪和 fail-stop 行为，不提供端到端的容错。\nMaterialize 作为一个需要在生产环境运行的数据库，必须解决这个问题。它和 Flink 都将持久化数据写入对象存储（S3），但持久化的内容不同，这导致了完全不同的权衡。\n持久化什么 Flink Checkpoint Materialize Persist 持久化的内容 算子的内部状态（join 的 hash map、agg 的累加器等）——本质上是索引/计算状态 Source 摄入的数据 + 物化视图的计算结果——$(data, time, diff)$ 三元组——本质上是数据 持久化方式 定期快照（基于 Chandy-Lamport 算法），通过一致性 barrier 协调所有算子同时写入 S3；支持全量快照和增量快照（仅写入变化部分） 持续增量追加，每批新数据作为不可变文件写入 S3，不需要全局 barrier 不持久化什么 checkpoint 之间的 state 变化（崩溃时丢失，需要从数据源重放） Arrangement（内存中的按 key 索引结构） 虽然 source 数据\u0026quot;已经在 Kafka 里了\u0026rdquo;，Materialize 仍然会把摄入的 source 数据持久化到自己的 persist 层（S3）。原因有几个：\nKafka 有 retention。Kafka topic 通常设置了保留策略（比如保留 7 天），过期数据会被删除。如果 Materialize 崩溃后需要恢复，而 Kafka 中的老数据已经被清理了，就无法从 Kafka 重新消费。 Reclocking 必须持久化。Source 摄入时，Materialize 需要将外部时间戳（Kafka offset、PostgreSQL LSN）映射到自己的逻辑时间戳——这个过程叫 reclocking。时间戳映射一旦做出就必须持久化，否则重启后重新消费同一条 Kafka 消息可能被分配到不同的逻辑时间戳，导致下游计算结果不一致。 避免重复连接。多个物化视图可能引用同一个 source。持久化后，下游的 dataflow 直接从 persist 层读取，不需要各自维护到 Kafka 的连接。 概念 存储位置 内容 Source 数据 持久化（S3） 从外部系统摄入的数据，包含 reclocking 后的逻辑时间戳 物化视图数据 持久化（S3） 物化视图的计算结果，$(data, time, diff)$ 三元组 Arrangement（索引） 仅内存 按 key 索引的多版本数据结构，用于 join 和 reduce 的快速查找 崩溃恢复与 Hydration Flink：从最近的 checkpoint 加载算子 state → 索引直接恢复 → 从 Kafka 重放 checkpoint 之后的少量数据。恢复快，因为大部分 state 直接从快照加载，只需要重放一小段数据。\nMaterialize：Compute replica 崩溃后，恢复过程叫做 hydration——从 persist 层（S3）读取 source 数据，把整个 dataflow 从头跑一遍，重建所有 arrangement（source 的、中间结果的、MV 输出的）。这是完整的重新计算，不只是读数据建索引。\n为什么要从头计算，而不是像 Flink 一样持久化中间状态（arrangement）然后直接加载？因为 Materialize 把 compute 层设计成完全无状态的——所有持久状态都在 persist 层（S3），compute replica 只有内存中的 arrangement，没有本地磁盘状态。这个设计带来了几个好处：\n多副本容错。可以为同一组 dataflow 运行多个 compute replica。一个 replica 挂了，其他 replica 继续服务查询，用户无感知。新 replica 从 S3 hydrate 即可，不需要从挂掉的 replica 拷贝 state。 弹性伸缩。加副本、扩容只需要起一个新 replica，让它从 S3 hydrate。不需要迁移或重分布任何 state。甚至可以临时起一个更大的 replica 来加速 hydration，完成后撤掉。 正常运行零 checkpoint 开销。Persist 层只做增量追加写入（source 数据和 MV 结果），不需要定期遍历所有算子 state 做全量快照、不需要全局 barrier 对齐。 代价是 hydration 较重。数据量大时（比如 100 GB），需要从 S3 加载全部 source 数据并重新执行所有计算（filter、join、reduce 等），耗时可能很长。Hydration 期间，依赖这些 dataflow 的查询会阻塞等待。\nHydration 完成后，replica 继续从 persist 层读取崩溃期间 Storage 层新写入的增量数据，进入正常的增量处理模式。\n权衡 Flink 在正常运行时持续付出代价，换取崩溃时的快速恢复。Materialize 在正常运行时不付出 checkpoint 代价，接受崩溃时较慢的恢复，但通过多副本避免恢复期间的服务中断。\nFlink checkpoint 的代价是持续的：\n定期遍历所有算子 state 做快照，state 越大（比如大的 join state），checkpoint 越慢 checkpoint 期间可能产生反压，造成处理延迟抖动 需要协调所有算子在同一个 barrier 上对齐 Materialize 的代价集中在恢复时：\n正常运行时只做增量追加写入，无 checkpoint 开销 崩溃后需要完整的 hydration（从 S3 读数据 + 重新计算），时间取决于数据量和查询复杂度 通过多副本缓解：只要还有一个 replica 存活，服务不中断 端到端的例子 让我们用一个多表场景串联整个流程，展示 Materialize 的完整工作方式。\n1. 创建数据源 1 2 3 4 5 6 7 8 9 10 CREATE CONNECTION kafka_conn TO KAFKA (BROKER \u0026#39;kafka:9092\u0026#39;); CREATE CONNECTION csr_conn TO CONFLUENT SCHEMA REGISTRY (URL \u0026#39;http://schema-registry:8081\u0026#39;); CREATE SOURCE orders FROM KAFKA CONNECTION kafka_conn (TOPIC \u0026#39;orders\u0026#39;) FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY CONNECTION csr_conn; CREATE SOURCE customers FROM KAFKA CONNECTION kafka_conn (TOPIC \u0026#39;customers\u0026#39;) FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY CONNECTION csr_conn; Storage 层建立两个 Kafka consumer，持续消费 orders 和 customers topic 的消息，将它们转换为 $(data, time, diff)$ 三元组注入系统。\n2. 创建物化视图 1 2 3 4 5 6 CREATE MATERIALIZED VIEW customer_spending AS SELECT c.name, c.region, sum(o.amount) AS total_spent FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;completed\u0026#39; GROUP BY c.name, c.region; 这条 SQL 经历完整的编译pipeline：\nMIR 阶段：\n1 2 3 4 5 6 Reduce[key=(c.name, c.region), agg=sum(o.amount)] Project[c.name, c.region, o.amount] Join[o.customer_id = c.id] Filter[o.status = \u0026#39;completed\u0026#39;] Get[orders] Get[customers] MIR 优化：谓词 o.status = 'completed' 已经在 Join 之前，投影下推丢弃不需要的列。\nLIR 阶段（物理计划选择）：\nJoin 策略：选择 delta join。生成两条pipeline： 当 orders 有变更时：$\\Delta \\text{orders}$ → 探测 customers 的 arrangement 当 customers 有变更时：$\\Delta \\text{customers}$ → 探测 orders 的 arrangement Reduce 策略：sum 是 accumulable，用累加器实现 渲染阶段：在 Compute 层创建 dataflow 图。需要两个 arrangement：orders 按 customer_id 索引，customers 按 id 索引。如果这两个 arrangement 已经存在（被之前的物化视图创建过），直接共享，不需要重复创建。\n3. 数据变更的传播 场景一：新订单到来。\nKafka 的 orders topic 收到一条新消息：{id: 5001, customer_id: 42, amount: 300, status: \u0026quot;completed\u0026quot;}。\n变更传播路径（沿 delta join 的 orders pipeline）：\n1 2 3 4 5 6 7 8 9 10 11 12 Kafka 消息 → Storage 层: (row, t₁, +1) → Filter[status=\u0026#39;completed\u0026#39;]: 满足条件，通过 → Delta Join: 探测 customers 的 arrangement，查找 customer_id = 42 找到 (id=42, name=\u0026#34;Alice\u0026#34;, region=\u0026#34;east\u0026#34;) 输出: ((\u0026#34;Alice\u0026#34;, \u0026#34;east\u0026#34;, 300), t₁, +1) → Reduce[sum]: key (\u0026#34;Alice\u0026#34;, \u0026#34;east\u0026#34;) 的累加器 += 300 旧值 2000 → 新值 2300 输出: ((\u0026#34;Alice\u0026#34;, \u0026#34;east\u0026#34;, 2000), t₁, -1) ((\u0026#34;Alice\u0026#34;, \u0026#34;east\u0026#34;, 2300), t₁, +1) → 物化视图 arrangement 更新 场景二：客户信息变更。\nAlice 从 east 区域调到 west 区域。Kafka 的 customers topic 收到更新：\n1 2 旧记录撤回: (id=42, name=\u0026#34;Alice\u0026#34;, region=\u0026#34;east\u0026#34;, t₂, -1) 新记录插入: (id=42, name=\u0026#34;Alice\u0026#34;, region=\u0026#34;west\u0026#34;, t₂, +1) 变更传播路径（沿 delta join 的 customers pipeline）：\n先处理撤回 $(42, \\text{\"Alice\"}, \\text{\"east\"}, t_2, -1)$：\n1 2 3 4 5 Delta Join: 探测 orders 的 arrangement，查找 customer_id = 42 找到 Alice 的所有已完成订单（假设总金额 2300） 输出 join 增量，经过 Reduce 后: key (\u0026#34;Alice\u0026#34;, \u0026#34;east\u0026#34;) 的 sum 变为 0 输出: ((\u0026#34;Alice\u0026#34;, \u0026#34;east\u0026#34;, 2300), t₂, -1) 再处理插入 $(42, \\text{\"Alice\"}, \\text{\"west\"}, t_2, +1)$：\n1 2 3 4 5 Delta Join: 同样探测 orders 的 arrangement 找到 Alice 的所有已完成订单（同样的 2300） 输出 join 增量，经过 Reduce 后: key (\u0026#34;Alice\u0026#34;, \u0026#34;west\u0026#34;) 的 sum 变为 2300 输出: ((\u0026#34;Alice\u0026#34;, \u0026#34;west\u0026#34;, 2300), t₂, +1) 最终效果：物化视图中 (\u0026quot;Alice\u0026quot;, \u0026quot;east\u0026quot;, 2300) 被删除，(\u0026quot;Alice\u0026quot;, \u0026quot;west\u0026quot;, 2300) 被插入——Alice 的消费总额从 east 区域\u0026quot;转移\u0026quot;到了 west 区域。全程不需要重算任何其他客户的数据。\n4. 查询 1 SELECT * FROM customer_spending WHERE region = \u0026#39;east\u0026#39;; 协调器分配时间戳 $T_{read}$，等待 Compute 层的 frontier 推进到 $T_{read}$ 之后，从物化视图的 arrangement 中直接查找 region = 'east' 的记录。结果立即返回，无需重新计算。\n5. 流式订阅 1 SUBSCRIBE (TABLE customer_spending) WITH (SNAPSHOT = false); 客户端会持续收到物化视图的变更：\n1 2 3 4 5 6 timestamp | diff | name | region | total_spent -----------+------+---------+--------+------------ t₁ | -1 | Alice | east | 2000 t₁ | 1 | Alice | east | 2300 t₂ | -1 | Alice | east | 2300 t₂ | 1 | Alice | west | 2300 下游系统可以实时消费这些变更，触发告警、更新仪表盘、或写入另一个数据库。\n与 Flink SQL 的对比 Flink SQL 也支持流式查询和物化视图语义，表面上和 Materialize 做的事情相似。但两者从定位到实现都有本质区别。\n定位不同：数据库 vs 流处理引擎 Materialize 是一个数据库。你用 psql 连接它，CREATE TABLE、CREATE MATERIALIZED VIEW、SELECT、SUBSCRIBE——交互方式和 PostgreSQL 一样。物化视图创建后持续存在，随时可以查询，返回一致性快照。\nFlink 是一个流处理引擎。你编写一个流处理作业（Java 代码或 Flink SQL 脚本），提交到集群运行。作业的输出写入外部 sink（Kafka、数据库、文件系统）。你不能随时 SELECT 一个正在运行的 Flink SQL 查询的中间结果——结果在 sink 中，不在 Flink 中。\n这个区别看起来是接口层面的，但它影响了整个系统的设计选择。\n一致性 Materialize 提供严格可串行化。每个 SELECT 返回的是某个逻辑时间点的一致性快照——协调器分配时间戳，等待 Compute 层的 frontier 推进，确保所有源的所有变更都已处理完毕后才返回结果。你不会看到\u0026quot;join 的左侧已经更新但右侧还没有\u0026quot;这种半更新状态。\nFlink SQL 没有这种全局一致性保证。每个算子独立处理到达的记录，输出的结果在传播过程中可能处于不同的\u0026quot;进度\u0026quot;。Flink 的 watermark 机制保证的是事件时间的进度推进，不是跨算子的一致性快照。对于很多流处理场景来说这足够了，但如果你需要的是\u0026quot;任意时刻查询都返回一致结果\u0026quot;，Flink 做不到。\nState 共享与 Delta Join 上一篇文章已经详细讨论了这个区别。Flink 的 state 是 per-operator 私有的，多路 join 只能级联二路 join，每级维护自己的中间状态。Materialize 的 arrangement 跨算子共享，支持 delta join——多路 join 不需要中间结果的 arrangement，直接探测基表的共享索引。\n在实际场景中，一个 Materialize 实例上可能运行着几十个物化视图，它们共享底层表的 arrangement。新增一个物化视图时，如果它需要的 arrangement 已经存在，不需要额外的内存开销。在 Flink 中，每个作业的 state 是独立的，即使两个作业引用同一张表，也各自维护一份 state。\n容错策略 前面已经详细讨论过：Flink 用 Chandy-Lamport checkpoint（定期快照所有算子 state），Materialize 用 persist 层持久化数据 + 重建 arrangement。两种方案各有权衡。\n查询能力 Flink SQL 和标准 SQL 有一些语义差异。例如 Flink 的流式 GROUP BY 聚合默认产生的是 retraction 流（不断更新的结果），而不是标准 SQL 中 GROUP BY 返回的确定性结果。Flink 需要通过 window 或其他机制来定义\u0026quot;什么时候结果是最终的\u0026quot;。\nMaterialize 的 SQL 语义与 PostgreSQL 对齐。SELECT * FROM my_materialized_view 返回的就是当前时刻的确定性结果，和查一张普通表没有区别。这种\u0026quot;看起来就是个普通数据库\u0026quot;的体验是 Materialize 的核心设计目标。\n适用场景 选 Materialize 的场景：你需要一个\u0026quot;实时更新的数据库\u0026quot;——物化视图持续维护，随时可以用标准 SQL 查询，需要一致性保证，多个视图共享底层数据。典型场景：实时仪表盘、实时特征工程、事件驱动的业务逻辑。\n选 Flink 的场景：你需要一个\u0026quot;流式数据管道\u0026quot;——从 Kafka 读数据，经过复杂的流式 ETL（窗口聚合、CEP、异步 I/O），写到另一个系统。Flink 的生态更成熟，connector 更丰富，窗口语义更完善，容错经过了大规模生产验证。\n两者解决的是不同层面的问题。Materialize 可以用 Flink 的 Kafka 输出作为自己的数据源——Flink 做流式 ETL，Materialize 做实时物化视图，各自发挥优势。\n三层抽象的价值 回顾整个系列，三个系统各自解决了一个关键问题，层层递进：\nTimely Dataflow 解决了**\u0026ldquo;如何知道计算完成了\u0026rdquo;**。在有环的分布式数据流图中，通过 pointstamp 和 could-result-in 关系精确追踪进度。没有这个基础，你无法在有迭代的场景中做正确的计算。\nDifferential Dataflow 解决了**\u0026ldquo;如何只算变化的部分\u0026rdquo;**。通过 $(data, time, diff)$ 三元组和 arrangement 数据结构，让任意关系代数操作都能增量化执行。没有这个基础，每次数据变化都要全量重算。\nMaterialize 解决了**\u0026ldquo;如何让用户用 SQL 表达增量计算\u0026rdquo;**。通过一条完整的编译pipeline（SQL → MIR → LIR → Dataflow），将用户的 SQL 查询自动转换为持续运行的增量计算图。用户不需要理解 dataflow、arrangement、pointstamp 这些底层概念——写一条 CREATE MATERIALIZED VIEW，系统自动完成剩下的一切。\n这种分层的设计也让每一层可以独立演进。Timely dataflow 可以优化进度追踪的效率而不影响 differential dataflow 的语义。Differential dataflow 可以改进 arrangement 的数据结构而不影响 SQL 层的编译。Materialize 可以增加新的 SQL 功能而不需要修改底层的增量计算引擎。\n最终的结果是：用户写一条 SQL，背后是一个精确追踪进度的、自动增量化的、持续运行的分布式计算图。\n参考资料 Murray et al., Naiad: A Timely Dataflow System, SOSP 2013 McSherry et al., Differential Dataflow, CIDR 2013 timely-dataflow GitHub 仓库 differential-dataflow GitHub 仓库 Materialize 文档 Materialize GitHub 仓库 ","permalink":"https://luoyuxia.github.io/posts/materialize%E7%94%A8-differential-dataflow-%E6%9E%84%E5%BB%BA%E5%AE%9E%E6%97%B6-sql-%E6%95%B0%E6%8D%AE%E5%BA%93/","summary":"系列文章的最后一篇。Materialize 在 timely dataflow 和 differential dataflow 之上构建了一个完整的 SQL 数据库，将 SQL 查询编译为增量维护的 dataflow 图，实现物化视图的实时更新。","title":"Materialize：用 Differential Dataflow 构建实时 SQL 数据库"},{"content":" 论文：Differential Dataflow 作者：Frank McSherry, Derek G. Murray, Rebecca Isaacs, Michael Isard 发表：CIDR 2013\n这是系列文章的第二篇。\nTimely Dataflow：用一个支持有环图的数据流模型，统一 batch、streaming 和 iterative 三种计算范式 Differential Dataflow（本篇）：如何在 timely dataflow 之上实现通用的增量计算 Materialize：如何用 dataflow 引擎构建一个实时 SQL 数据库 为什么需要增量计算 上一篇文章介绍的 timely dataflow 用一个计算模型统一了 batch、streaming 和 iterative 三种范式。但它本身并不直接解决另一个同样重要的问题：当输入数据变化时，如何避免从头重算？\n批处理的痛点 假设你在一个社交网络上运行 PageRank。你有 10 亿条边，计算需要 30 分钟。现在有一个用户新关注了另一个用户——增加了一条边。你需要重新计算整个 PageRank 吗？\n在传统的批处理系统中，答案是：是的。即使输入只变化了 0.0000001%，整个计算也要从头做一遍。\n流处理的局限 2013 年前后的流处理系统（Storm、S4）对 append-only 的数据流处理得很好——新消息到来，触发更新。但它们很难处理撤回（retraction）。\n考虑一个简单的场景：你在维护一个 SELECT count(*) FROM users WHERE age \u0026gt; 18 的实时查询。一条新用户记录插入，count 加 1——很简单。但如果某个用户的年龄从 19 更新为 17 呢？你需要：\n知道这条记录之前满足过过滤条件 将它从计数中减去 对所有依赖这个计数的下游计算做相应更新 当查询更复杂——涉及多表 join、嵌套聚合、窗口函数——手动管理这些撤回的复杂度会爆炸式增长。每种算子都需要单独实现\u0026quot;如何处理输入变化\u0026quot;的逻辑。\n我们需要一种机制：当输入发生任意变化（插入、删除、修改）时，系统能自动地、通用地只重新计算受影响的部分。\n这就是 differential dataflow 要解决的问题。\n核心思想：差分（Difference） Differential dataflow 的核心洞察非常简洁：\n不要把数据当作\u0026quot;集合\u0026quot;来操作，把它当作\u0026quot;变更流\u0026quot;来操作。\n数据的三元组表示 在 differential dataflow 中，一条数据不再是简单的一行记录，而是一个三元组：\n$$(data, time, diff)$$ data：数据内容本身（例如一行记录 (\u0026quot;Alice\u0026quot;, 25)） time：这条变更发生的逻辑时间戳（继承自 timely dataflow 的偏序时间戳） diff：一个整数，表示这条数据的\u0026quot;变化量\u0026quot; $+1$：插入一条记录 $-1$：删除一条记录 更一般地，diff 可以是任意整数（甚至是其他满足阿贝尔群性质的类型） 为什么 diff 需要满足阿贝尔群？因为增量计算的核心操作是\u0026quot;累加\u0026quot;和\u0026quot;抵消\u0026quot;——插入 $(+1)$ 和删除 $(-1)$ 需要能互相抵消，而且累加的顺序不能影响结果（交换律）。整数加法天然满足这些要求。\n集合 = 历史变更的累加 Differential dataflow 不存储集合本身，只存储变更。那么\u0026quot;某个时间点的集合长什么样\u0026quot;是怎么回答的？\n答案是累加。在任意时间点 $T$，要判断一条数据 $d$ 是否在集合中，将所有时间 $\\leq T$ 的 diff 求和：\n$$\\text{count}(d, T) = \\sum_{t \\leq T} \\text{diff}(d, t)$$ 累加结果为 1：$d$ 在集合中（经历过一次插入，没有被删除） 累加结果为 0：$d$ 不在集合中（要么从未插入，要么插入后又被删除，$+1$ 和 $-1$ 抵消了） 累加结果为 2 或更大：$d$ 在集合中出现了多份——这是多重集合（multiset）语义，在 SQL 中对应没有去重的场景（例如 SELECT 不带 DISTINCT） 一个简单的例子。假设数据 $d$ 的变更历史是：\ntime diff 含义 $t_1$ $+1$ 插入 $t_2$ $-1$ 删除 $t_3$ $+1$ 再次插入 那么：$\\text{count}(d, t_1) = 1$（在），$\\text{count}(d, t_2) = 1 - 1 = 0$（不在），$\\text{count}(d, t_3) = 1 - 1 + 1 = 1$（又回来了）。\n系统不需要记住\u0026quot;集合当前有哪些元素\u0026quot;，只需要记住所有的变更。任何时间点的集合状态都可以从变更历史中恢复。\n注意公式中的 $\\leq$ 是偏序关系，这意味着只有那些\u0026quot;肯定在 $T$ 之前或等于 $T$\u0026ldquo;的时间戳上的变更才会被累加。偏序中不可比较的时间戳上的变更不会被包含——这保证了并发处理时的一致性。\n一个具体的例子 假设我们在维护一个图的边集合，并实时计算每个节点的出度。\n初始状态（时间 $t_0$）：\ndata time diff (A→B) $t_0$ +1 (A→C) $t_0$ +1 (B→C) $t_0$ +1 此时 A 的出度 = 2，B 的出度 = 1，C 的出度 = 0。\n现在在时间 $t_1$，删除边 A→C，增加边 C→A：\ndata time diff (A→C) $t_1$ -1 (C→A) $t_1$ +1 对于出度计算来说，需要处理的增量是：\nA 的出度：因为 A→C 被删除，出度 -1，从 2 变为 1 C 的出度：因为 C→A 被增加，出度 +1，从 0 变为 1 B 的出度：不受影响，无需重算 这就是差分的威力：变更只沿着受影响的路径传播。B 的出度从未被涉及，所以零计算开销。\n修改 = 删除 + 插入 值得强调的是，differential dataflow 中没有\u0026quot;修改\u0026quot;这个原子操作。一次修改被表示为先删除旧值，再插入新值。例如，用户 Alice 的年龄从 25 改为 26：\ndata time diff (\u0026ldquo;Alice\u0026rdquo;, 25) $t_2$ -1 (\u0026ldquo;Alice\u0026rdquo;, 26) $t_2$ +1 这看起来冗余，但它使得整个系统只需要处理一种语义——累加差分——而不需要为\u0026quot;修改\u0026quot;单独设计逻辑。所有下游算子都用同样的方式处理插入和删除，修改的语义自然正确。\n算子如何增量化 有了 $(data, time, diff)$ 的表示后，关键问题是：关系代数的各种操作（map、filter、join、reduce）如何在这种变更流上正确工作？\n一个基本前提：在 differential dataflow 中，算子的输入是变更流，输出也是变更流。一个 map 算子消费 $(data, time, diff)$ 三元组，产出的也是 $(data', time', diff')$ 三元组。一个 join 算子消费两侧的变更流，产出的还是变更流。所有算子用同一种\u0026quot;语言\u0026rdquo;——$(data, time, diff)$——通信，所以它们可以任意串联组合。\n答案取决于算子是否需要维护状态。\n无状态算子：Map、Filter、Union、Negate 这些算子最简单，对每个输入增量独立计算输出增量，不需要任何历史状态：\nMap：输入 $(d, t, +1)$，输出 $(f(d), t, +1)$。函数 $f$ 作用在 data 上，time 和 diff 原样传递。删除（$-1$）也完全对称——如果 $f$ 将 A 映射到 B，那么\u0026quot;删除一个 A\u0026quot;自然转化为\u0026quot;删除一个 B\u0026quot;。\nFilter：输入 $(d, t, +1)$，如果 $d$ 满足谓词则原样输出，否则不输出。删除也对称——如果一条记录之前通过了过滤、现在被删除，filter 会传递这个删除。如果它之前就没通过过滤，删除也不会输出。\nUnion：将两侧的增量直接合并转发。两个集合的并集的增量，等于两侧增量的合并。\nNegate：翻转 diff 符号。$(d, t, +1)$ 变成 $(d, t, -1)$。这个操作用于实现集合差（EXCEPT）——$A \\setminus B = A \\cup \\text{Negate}(B)$。\n这些算子的增量化是\u0026quot;免费\u0026quot;的——每条输入增量独立处理，不需要额外的存储或计算，无论数据量多大。\n有状态算子：Join Join 是关系代数中最核心的操作之一，也是增量化中最有趣的算子。两个 collection $A$ 和 $B$ 按 key 做 join，当一侧有新的增量时，需要在另一侧查找所有匹配的历史记录。\n这意味着 join 需要\u0026quot;记忆\u0026quot;——它必须知道对方 collection 中当前有哪些数据。 这个记忆就是后面会详细介绍的 arrangement。\nJoin 的增量计算规则 当 A 侧收到一条增量 $(k, v_a, t_a, \\Delta_a)$ 时：\n在 B 侧的 arrangement 中查找 key 为 $k$ 的所有历史变更 对于每个匹配的 $(k, v_b, t_b, \\Delta_b)$，输出 $((k, v_a, v_b),\\ t_a \\lor t_b,\\ \\Delta_a \\cdot \\Delta_b)$ 反过来，当 B 侧有增量时，也用同样的方式探测 A 侧。\n两个细节值得深入理解。\n为什么时间戳取最小上界 输出的时间戳是 $t_a \\lor t_b$，即两个时间戳在偏序格上的最小上界（lattice join，也叫 least upper bound）。这个选择由正确性约束唯一确定。\n回忆差分表示的语义：在任意时间 $T$，一条数据是否在集合中，取决于所有 $\\leq T$ 的 diff 之和是否不为零。这意味着：\n正确性约束：对于任意查询时间 $T$，增量计算得到的 join 结果在 $T$ 时的累积值，必须等于\u0026quot;将 A 在 $T$ 时的完整集合与 B 在 $T$ 时的完整集合做 join\u0026quot;的结果。\n假设 A 侧在时间 $t_a$ 插入了 $(k, v_a)$，B 侧在时间 $t_b$ 插入了 $(k, v_b)$。join 结果 $(k, v_a, v_b)$ 应该被赋予什么时间戳 $t_{out}$？\n根据正确性约束，$t_{out}$ 必须满足：\n当 $T \\geq t_a$ 且 $T \\geq t_b$ 时：两侧的数据都存在于各自的集合中，所以 join 结果也应该存在。这要求 $t_{out} \\leq T$——即 join 结果的时间戳不能晚于 $T$，否则它不会被累加进来。 当 $T \\not\\geq t_a$ 或 $T \\not\\geq t_b$ 时：至少有一侧的数据还不存在，join 结果也不应该存在。这要求 $t_{out} \\not\\leq T$——即 join 结果的时间戳不能早于或等于 $T$。 综合这两条：$t_{out} \\leq T$ 当且仅当 $t_a \\leq T$ 且 $t_b \\leq T$。\n满足这个条件的 $t_{out}$ 是什么？正是 $t_a$ 和 $t_b$ 的最小上界 $t_a \\lor t_b$。这是最小上界的定义本身：$t_a \\lor t_b$ 是满足 $t_a \\leq t_a \\lor t_b$ 且 $t_b \\leq t_a \\lor t_b$ 的最小元素，因此对于任意 $T$：\n$$t_a \\lor t_b \\leq T \\iff t_a \\leq T \\text{ 且 } t_b \\leq T$$这不是\u0026quot;直觉上合理\u0026quot;——这是数学上唯一正确的选择。\n全序下的情况 当时间戳是全序的（例如简单的整数时间 $1, 2, 3, \\ldots$），任意两个时间戳都可比较，最小上界就是 $\\max$：\n$$t_a \\lor t_b = \\max(t_a, t_b)$$例如，A 侧的数据出现在时间 3，B 侧的数据出现在时间 5。join 结果的时间戳是 $\\max(3, 5) = 5$。在时间 4 查询时，B 侧的数据还不存在（$5 \\not\\leq 4$），所以 join 结果也不存在（$5 \\not\\leq 4$），正确。在时间 5 查询时，两侧都存在了，join 结果也存在，正确。\n偏序下的情况 当时间戳是偏序的（例如 timely dataflow 的 $(epoch, \\langle iteration \\rangle)$），情况更有趣。两个不可比较的时间戳也有最小上界——各分量取 max：\n$$(e_1, \\langle c_1 \\rangle) \\lor (e_2, \\langle c_2 \\rangle) = (\\max(e_1, e_2), \\langle \\max(c_1, c_2) \\rangle)$$具体例子：$(1, \\langle 3 \\rangle) \\lor (2, \\langle 1 \\rangle) = (2, \\langle 3 \\rangle)$。\n验证：\n$(2, \\langle 3 \\rangle) \\geq (1, \\langle 3 \\rangle)$？$2 \\geq 1$ 且 $3 \\geq 3$，是。 $(2, \\langle 3 \\rangle) \\geq (2, \\langle 1 \\rangle)$？$2 \\geq 2$ 且 $3 \\geq 1$，是。 有没有更小的上界？试 $(2, \\langle 2 \\rangle)$：$\\geq (1, \\langle 3 \\rangle)$？$2 \\geq 1$ 但 $2 \u003c 3$，不是。试 $(1, \\langle 3 \\rangle)$：$\\geq (2, \\langle 1 \\rangle)$？$1 \u003c 2$，不是。所以 $(2, \\langle 3 \\rangle)$ 确实是最小的上界。 如果不用最小上界会怎样 假设我们错误地选择 $(2, \\langle 1 \\rangle)$ 作为输出时间戳（也许是因为\u0026quot;epoch 更大所以更晚\u0026quot;的直觉）。考虑在时间 $T = (2, \\langle 2 \\rangle)$ 查询：\n$T \\geq (2, \\langle 1 \\rangle)$？$2 \\geq 2$ 且 $2 \\geq 1$，是。所以 join 结果会出现在查询中。 但 A 侧数据在 $(1, \\langle 3 \\rangle)$，$T \\geq (1, \\langle 3 \\rangle)$？$2 \\geq 1$ 但 $2 \u003c 3$，不是。A 侧数据在 $T$ 时不存在。 矛盾：A 侧的数据还不存在，但 join 结果已经出现了——查询得到了一条\u0026quot;幽灵结果\u0026quot;，它引用了一条尚不存在的数据。这就是使用错误时间戳导致的不一致。\n只有最小上界能保证：join 结果恰好在\u0026quot;两侧数据都已就绪\u0026quot;的最早时刻出现，不早也不晚。\n为什么 diff 相乘 两个 diff 相乘得到输出 diff，原因如下：\n插入 × 插入 = 插入：$+1 \\cdot +1 = +1$。A 侧新增一行，B 侧已有一行匹配，join 输出新增一行。 插入 × 删除 = 删除：$+1 \\cdot (-1) = -1$。A 侧新增一行，但 B 侧的匹配行之前被删除了，join 结果中需要删除这个组合。 删除 × 删除 = 插入：$(-1) \\cdot (-1) = +1$。这种情况在实际执行中不会直接出现（因为 A 侧和 B 侧的变更不会在同一次 join 中同时作为\u0026quot;新增量\u0026quot;），但它保证了数学上的一致性。 一个具体的 Join 例子 假设有两个 collection，users 和 orders，按 user_id 做 join。下面逐步追踪每次变更时 join 的处理过程，同时展示两侧 arrangement 的变化。\n初始状态（时间 $t_0$）\nusers 的 arrangement：\nkey (user_id) value (name) time diff 1 Alice $t_0$ +1 2 Bob $t_0$ +1 orders 的 arrangement：\nkey (user_id) value (item) time diff 1 book $t_0$ +1 1 pen $t_0$ +1 此时 join 的完整结果是：{(1, Alice, book), (1, Alice, pen)}。Bob 没有订单，所以不出现。\n时间 $t_1$：Bob 下了一个订单 (2, laptop)。\norders 侧收到增量 $(2, \\text{laptop}, t_1, +1)$。这条增量首先被写入 orders 的 arrangement：\norders 的 arrangement（新增一行）：\nkey (user_id) value (item) time diff 1 book $t_0$ +1 1 pen $t_0$ +1 2 laptop $t_1$ +1 然后 join 算子用这条增量去探测 users 的 arrangement：\n查找 key = 2，找到 $(2, \\text{Bob}, t_0, +1)$ 输出 $((2, \\text{Bob}, \\text{laptop}),\\ t_0 \\lor t_1,\\ +1 \\cdot +1)$ 因为 $t_0 \u003c t_1$（假设全序时间），$t_0 \\lor t_1 = t_1$。输出 $((2, \\text{Bob}, \\text{laptop}),\\ t_1,\\ +1)$——在时间 $t_1$ 新增了一个 join 结果。\n时间 $t_2$：Alice 退回了 book。\norders 侧收到增量 $(1, \\text{book}, t_2, -1)$。写入 orders 的 arrangement：\norders 的 arrangement（新增一行）：\nkey (user_id) value (item) time diff 1 book $t_0$ +1 1 book $t_2$ -1 1 pen $t_0$ +1 2 laptop $t_1$ +1 注意 key=1, value=book 现在有两条变更记录。累加 diff：$+1 + (-1) = 0$，说明 book 在 $t_2$ 之后已不存在于 orders 中。但 arrangement 保留了两条历史记录——它需要支持多版本查询。\njoin 算子用这条增量探测 users 的 arrangement：\n查找 key = 1，找到 $(1, \\text{Alice}, t_0, +1)$ 输出 $((1, \\text{Alice}, \\text{book}),\\ t_0 \\lor t_2,\\ (-1) \\cdot (+1))$ 即 $((1, \\text{Alice}, \\text{book}),\\ t_2,\\ -1)$——在时间 $t_2$ 删除了一个 join 结果 时间 $t_3$：Alice 改名为 Alicia。\nusers 侧收到两条增量（修改 = 删除 + 插入）：$(1, \\text{Alice}, t_3, -1)$ 和 $(1, \\text{Alicia}, t_3, +1)$。写入 users 的 arrangement：\nusers 的 arrangement（新增两行）：\nkey (user_id) value (name) time diff 1 Alice $t_0$ +1 1 Alice $t_3$ -1 1 Alicia $t_3$ +1 2 Bob $t_0$ +1 这次是 users 侧有变更，所以 join 算子用这两条增量去探测 orders 的 arrangement。\n先处理删除 $(1, \\text{Alice}, t_3, -1)$：\n在 orders 的 arrangement 中查找 key = 1 找到两个 value 的变更历史：book 有 $(t_0, +1)$ 和 $(t_2, -1)$，pen 有 $(t_0, +1)$ 对于 book：两条历史都要参与计算，分别输出： $((1, \\text{Alice}, \\text{book}),\\ t_3,\\ (-1) \\cdot (+1) = -1)$（对应 $t_0$ 的 +1） $((1, \\text{Alice}, \\text{book}),\\ t_3,\\ (-1) \\cdot (-1) = +1)$（对应 $t_2$ 的 -1） 对于 pen：输出 $((1, \\text{Alice}, \\text{pen}),\\ t_3,\\ (-1) \\cdot (+1) = -1)$ 再处理插入 $(1, \\text{Alicia}, t_3, +1)$：\n同样在 orders 中查找 key = 1，同样的历史 对于 book： $((1, \\text{Alicia}, \\text{book}),\\ t_3,\\ (+1) \\cdot (+1) = +1)$ $((1, \\text{Alicia}, \\text{book}),\\ t_3,\\ (+1) \\cdot (-1) = -1)$ 对于 pen：输出 $((1, \\text{Alicia}, \\text{pen}),\\ t_3,\\ (+1) \\cdot (+1) = +1)$ 输出的增量看起来很多，但按 $(data, time)$ 分组累加后：\ndata time 累加 diff (1, Alice, book) $t_3$ $-1 + 1 = 0$ (1, Alice, pen) $t_3$ $-1$ (1, Alicia, book) $t_3$ $+1 + (-1) = 0$ (1, Alicia, pen) $t_3$ $+1$ diff 为 0 的项可以消除（没有实际变化）。最终的净增量只有两条：\n$((1, \\text{Alice}, \\text{pen}),\\ t_3,\\ -1)$ $((1, \\text{Alicia}, \\text{pen}),\\ t_3,\\ +1)$ 效果：join 结果中 (1, Alice, pen) 被替换为 (1, Alicia, pen)。涉及 book 的变更互相抵消了——因为 book 在 $t_2$ 就已经不存在了，改名不影响它。Bob 的 join 结果也完全不受影响。\n有状态算子：Reduce Reduce（聚合）是最复杂的增量化算子。它按 key 分组后对每组的 values 应用聚合函数（如 count、sum、max、自定义函数等），并输出每个 key 的聚合结果。\nReduce 的增量计算逻辑 与 join 不同，reduce 的增量化不能简单地\u0026quot;用输入增量直接计算输出增量\u0026quot;。原因是聚合函数通常不是线性的——知道\u0026quot;新增了一个 value\u0026quot;并不总能直接推出\u0026quot;聚合结果变化了多少\u0026quot;。例如，max 操作：新增一个值时，如果它比当前最大值大，输出变化；如果比当前最大值小，输出不变。你必须知道当前最大值才能做判断。\n因此 reduce 的增量化采取了一种重算再做差的策略：\n从输入的 arrangement 中获取 key $k$ 在时间 $t$ 的完整输入集合（通过累加所有 $\\leq t$ 的历史变更） 对完整集合重新计算聚合结果 与之前缓存的输出结果比较 输出新旧结果之间的差异 一个具体的 Reduce 例子 假设我们在计算每个部门的员工人数：SELECT dept, count(*) FROM employees GROUP BY dept。\n初始状态（时间 $t_0$），employees 的 arrangement：\nkey (dept) value (name) time diff engineering Alice $t_0$ +1 engineering Bob $t_0$ +1 engineering Charlie $t_0$ +1 sales David $t_0$ +1 reduce 的初始输出：\ndata time diff (engineering, 3) $t_0$ +1 (sales, 1) $t_0$ +1 时间 $t_1$：Alice 从 engineering 转到 sales。 输入增量：\nkey (dept) value (name) time diff engineering Alice $t_1$ -1 sales Alice $t_1$ +1 Reduce 的处理过程：\n对 key = engineering：\n累加所有 $\\leq t_1$ 的变更：Alice $(+1, -1)$ = 0（不在了），Bob $(+1)$ = 1，Charlie $(+1)$ = 1 新的完整集合 = {Bob, Charlie}，count = 2 之前的输出是 (engineering, 3) 输出增量：$((\\text{engineering}, 3), t_1, -1)$ 和 $((\\text{engineering}, 2), t_1, +1)$ 对 key = sales：\n累加所有 $\\leq t_1$ 的变更：David $(+1)$ = 1，Alice $(+1)$ = 1 新的完整集合 = {David, Alice}，count = 2 之前的输出是 (sales, 1) 输出增量：$((\\text{sales}, 1), t_1, -1)$ 和 $((\\text{sales}, 2), t_1, +1)$ 注意输出的增量格式：旧结果撤回（$-1$），新结果插入（$+1$）。这种\u0026quot;先撤后插\u0026quot;的模式使得下游算子——无论是另一个 reduce、一个 join 还是任何其他算子——都能用统一的差分语义正确处理变更。\n另一个关键点：只有受影响的 key 被重算。 engineering 和 sales 的 count 需要重新计算，但其他部门（如果有的话）完全不受影响。当 10 亿条记录中只有 1 条发生变化时，只有 1 个（或少数几个）key 的聚合需要重算——这就是 per-key 增量化的效率所在。\n特殊聚合的优化 虽然 reduce 的通用策略是\u0026quot;重算再做差\u0026quot;，但对于某些特定的聚合函数，可以做得更高效：\n可累加聚合（sum、count）： 这些聚合函数是线性的——知道 diff 就能直接计算输出变化，不需要回溯历史。sum 的增量就是新增值 × diff，count 的增量就是 diff。\n层级聚合（min、max、top-k）： 这些聚合不能直接从 diff 推导，但可以通过维护一个有序数据结构来加速。例如 max：维护一个有序列表，新增值只需检查是否超过当前最大值。\n通用策略保证了正确性，特殊优化提高了效率。系统可以在编译时识别聚合函数的类型，自动选择最优的实现路径。\n任意组合自动增量化 这里体现了 differential dataflow 最核心的设计：增量化是在算子层面实现的，而不是为特定查询推导增量规则。\n每个算子独立地将输入增量转换为输出增量。算子之间通过 $(data, time, diff)$ 三元组通信，接口完全统一。无论你怎么组合这些算子——map 接 filter 接 join 接 reduce 再接另一个 join——增量会自动逐层传播。你不需要为每种新的查询模式去推导它应该如何增量维护。\n举一个稍复杂的例子。假设查询是：\n1 2 3 4 SELECT u.dept, count(*) FROM users u JOIN orders o ON u.id = o.user_id WHERE o.amount \u0026gt; 100 GROUP BY u.dept 对应的 dataflow pipeline是：orders → filter(amount \u0026gt; 100) → join(users) → map(extract dept) → reduce(count)\n当 orders 中新增一条记录 $(user\\_id=1, amount=150)$ 时，增量的传播路径：\nfilter：$150 \u003e 100$，通过。输出增量：$((\\text{user\\_id}=1, \\text{amount}=150), t, +1)$ join：在 users 的 arrangement 中查找 user_id = 1，找到 Alice（engineering 部门）。输出增量：$((\\text{Alice}, \\text{engineering}, 150), t, +1)$ map：提取 dept。输出增量：$(\\text{engineering}, t, +1)$ reduce：engineering 的 count 从 5 变为 6。输出增量：$((\\text{engineering}, 5), t, -1)$ 和 $((\\text{engineering}, 6), t, +1)$ 如果这条订单的 amount 是 50（不满足 filter），则在第 1 步就被丢弃，后续所有算子零开销。如果 user_id = 1 在 users 中不存在，join 在第 2 步不会产生匹配，后续也是零开销。增量沿着受影响的路径传播，不受影响的路径完全静默。\nIterate：增量化的迭代 Differential dataflow 的迭代建立在 timely dataflow 的循环结构之上。上一篇文章详细介绍了 timely dataflow 如何用循环计数器和 pointstamp 追踪来支持有环图中的迭代——differential dataflow 在此之上加入了增量语义。\n迭代收敛 循环中的每一轮迭代对应 timely dataflow 的一个循环计数器增量。在循环内，differential dataflow 的算子正常处理增量——每轮迭代的输出变更流入下一轮作为输入。当某一轮迭代不再产生任何新的增量时，timely 的进度追踪机制自动检测到没有活跃的 pointstamp 了，frontier 推进，迭代终止。\n这里 differential dataflow 和 timely dataflow 的配合非常精妙：\nTimely 负责判断\u0026quot;什么时候这一轮迭代的所有变更都已处理完\u0026quot;（通过 pointstamp 和 capability 追踪） Differential 负责判断\u0026quot;这一轮迭代是否产生了任何新的变更\u0026quot;（通过变更流是否为空） 当一轮迭代不再产生任何变更，迭代收敛 增量化的迭代：外部输入变更时不必从头迭代 迭代的真正威力在于增量更新场景。考虑一个图的连通分量（connected components）计算——这是一个典型的迭代算法：每个节点不断将自己知道的最小标签传播给邻居，直到所有连通分量中的节点都持有相同的最小标签。\n假设初始图有 100 万个节点，经过 10 轮迭代收敛后，连通分量计算完成。现在图中新增了一条边 $(u, v)$，连接了两个之前不连通的分量。\n在传统批处理系统中，你需要从头开始：重新加载 100 万个节点，重新跑 10 轮迭代。\n在 differential dataflow 中，系统将这条新边作为增量注入循环。假设 $u$ 所在分量的标签是 3，$v$ 所在分量的标签是 7。新边的增量导致 $v$ 的标签从 7 更新为 3——只有这一个节点的标签变了。这个变更传播到 $v$ 的邻居，它们的标签也可能更新……但传播只会影响 $v$ 所在的旧分量中的节点。$u$ 所在分量的节点标签不变，其他不相关的分量也完全不受影响。\n最终，系统只重新迭代了受影响的那部分节点，迭代轮数也可能远少于初始计算——因为大部分结构已经稳定了。\nArrangement：增量计算的核心数据结构 上一节提到 join 和 reduce 需要访问历史状态——join 需要查找对方 collection 中 key 匹配的记录，reduce 需要获取某个 key 的完整输入集合。这个\u0026quot;历史状态\u0026quot;就是 arrangement——differential dataflow 中最重要的数据结构。\n为什么需要 Arrangement 考虑上面的 join 例子。当 orders 侧收到 $(1, \\text{book}, t_2, -1)$ 时，系统需要在 users 侧查找 key = 1 的所有数据。这意味着 users 的数据必须以某种方式被索引，使得按 key 查找是高效的。\n但仅仅有索引还不够。Timely dataflow 中不同时间戳的数据可能在同时处理（偏序时间戳允许并发）。考虑这种场景：在 epoch 1 的第 3 轮迭代正在进行时，epoch 2 的第 1 轮迭代也在同时进行。两者查询 arrangement 时，看到的数据应该不同——epoch 2 的查询应该看到 epoch 2 之前的所有变更，而 epoch 1 的查询只应该看到 epoch 1 之前的变更。\nArrangement 需要是多版本的——它必须能回答\u0026quot;在时间 $t$ 时刻，key 为 $k$ 的数据有哪些？\u0026ldquo;这个问题，其中 $t$ 可以是偏序中的任意时间戳。\nArrangement 的结构 一个 arrangement 在逻辑上是一个从 $(key, value)$ 到变更历史的映射：\n1 key → value → [(time₁, diff₁), (time₂, diff₂), ...] 对于每个 $(key, value)$ 对，arrangement 记录了它在不同时间点的所有变更。要查询时间 $T$ 时某个 key 的当前状态，需要：\n找到该 key 下的所有 $(value, time, diff)$ 三元组 过滤出 $time \\leq T$ 的变更 按 $(value)$ 分组，对 diff 求和 保留 sum 不为零的 value 在物理实现上，arrangement 由两部分组成：\nBatch：一批新数据到来后形成的有序段。每个 batch 内部按 $(key, value, time)$ 排序，支持高效的二分查找。一个 batch 通常对应一个时间戳区间内的所有变更。\nTrace：多个 batch 的集合，加上合并管理逻辑。随着时间推进，trace 中的 batch 会被逐步合并——两个相邻的 batch 可以归并排序为一个更大的 batch。合并时，相同 $(key, value, time)$ 的 diff 会被累加；如果累加结果为 0，这条记录可以删除。\n这种 batch + trace 的分层结构类似于 LSM-Tree 的设计理念——新数据快速写入小的 batch，后台异步将 batch 合并为更大的段，保持读取效率。\nCompaction（压缩） 随着时间推进，arrangement 中累积的历史变更会越来越多。如果我们不再需要查询时间 $T$ 之前的历史状态，可以将旧的变更\u0026quot;折叠\u0026rdquo;。这就是 logical compaction。\n具体来说，compaction frontier 是一个时间戳 $T_{since}$，表示\u0026quot;我们不再关心任何严格早于 $T_{since}$ 的时间点的状态\u0026quot;。Compaction 的操作是：对于每个 $(key, value)$，将所有 $time \u003c T_{since}$ 的变更的 diff 累加，合并为一条时间戳为 $T_{since}$ 的变更。\n例如，某个 $(key, value)$ 对的变更历史是：\n1 (t₁, +1), (t₂, -1), (t₃, +1), (t₄, +1) 如果 compaction frontier 推进到 $t_3$，前三条记录合并为：\n1 (t₃, +1), (t₄, +1) 因为 $+1 - 1 + 1 = +1$，在 $t_3$ 时刻的累积状态就是\u0026quot;存在一份\u0026quot;。$t_4$ 的变更在 frontier 之后，保持不变。\nCompaction 后，你仍然可以正确查询 $t_3$ 及之后任意时间点的状态，但无法再查询 $t_1$ 或 $t_2$ 时的状态——那些信息已经被折叠了。在大多数场景下，我们只关心当前和最近的状态，不需要回溯很久以前的历史，所以 compaction 是一个很好的时间-空间权衡。\nSharing（共享） Arrangement 的另一个关键特性是共享。如果多个算子都需要按相同的 key 索引同一个 collection，它们可以共享同一个 arrangement，而不需要各自维护一份副本。\n这在实际系统中极为重要。考虑一个场景：数据库中有一张 users 表，上面建了 10 个物化视图，其中 6 个都按 user_id 做 join。如果每个视图都维护自己的 users 按 user_id 索引的副本，内存用量是 6 倍。共享 arrangement 后，只需要一份。\n共享也意味着当 users 表有更新时，arrangement 只需要更新一次，所有共享它的算子都能看到最新的数据。\n时间戳与正确性 到这里，我们已经看到了 differential dataflow 的主要机制：差分表示、各类算子的增量化、arrangement。但还有一个关键问题没有讨论：这些增量计算的结果一定是正确的吗？\n正确性的含义是：对于任意时间点 $t$，增量计算的结果必须与\u0026quot;用完整数据从头算\u0026quot;的结果完全一致。换句话说，增量计算是一种优化，不改变语义。\n这个正确性保证来自两个层面：\nTimely dataflow 保证了执行顺序的正确性。 通过 pointstamp 和 frontier 机制，系统确保一个算子在时间 $t$ 上的所有输入都到齐后，才会触发该时间戳的 notification。这意味着 reduce 在计算 key $k$ 在时间 $t$ 的聚合时，能看到所有 $\\leq t$ 的输入变更——不会遗漏，也不会包含未来的变更。\n差分表示保证了数学上的正确性。 $(data, time, diff)$ 本质上是一个函数 $f: D \\times T \\to \\mathbb{Z}$，每个算子对应一个在这个函数空间上的变换。只要每个算子的变换保持了差分语义的正确性（即输出的差分在累加后等于\u0026quot;对完整数据集应用该算子\u0026quot;的结果），那么任意组合也保持正确。这是通过归纳法证明的——每个基本算子的正确性独立验证，组合的正确性由此推出。\n偏序时间戳在这里起着关键作用。它确保了并发处理不会引入不一致——两个不可比较的时间戳上的变更不会相互干扰，因为它们对应的是独立的计算路径。\n与传统增量视图维护（IVM）的对比 关系型数据库中，增量视图维护（Incremental View Maintenance）是一个研究了几十年的课题。理解 differential dataflow 与 IVM 的区别，有助于看清它的创新之处。\nIVM 的方法 传统 IVM 的核心思路是按算子类型预先推导增量维护规则，然后在创建物化视图时根据查询结构套用这些规则。\n数据库系统内置了一组规则，例如：\nSELECT-PROJECT-JOIN 视图：当基表 A 中插入一行 $\\delta a$ 时，视图的增量是 $\\delta a \\bowtie B$；当 B 中删除一行 $\\delta b$ 时，视图的增量是 $A \\bowtie (-\\delta b)$。 简单聚合视图（COUNT、SUM）：维护一个计数器或累加器，基表变更时直接加减。 DISTINCT 视图：维护每个值的出现次数，加减后判断是否跨越 0/1 边界。 对于这些基础模式，规则是通用的——不需要为每个 SQL 单独推导。但问题在于，当多种算子组合在一起时：\n1 2 3 4 5 6 CREATE VIEW v AS SELECT dept, avg(salary) FROM employees e JOIN departments d ON e.dept_id = d.id WHERE d.location = \u0026#39;NYC\u0026#39; GROUP BY dept HAVING avg(salary) \u0026gt; 100000 系统需要把 JOIN 的增量规则、WHERE 的过滤规则、GROUP BY + AVG 的聚合规则、HAVING 的后过滤规则串联起来，推导出端到端的增量维护逻辑。这种组合推导的复杂度随算子种类增长而急剧上升：\n问题一：支持的查询模式有限。 简单的 select-project-join 没问题，但一旦涉及外连接、窗口函数、子查询嵌套、DISTINCT + 聚合的组合，推导正确的组合规则越来越困难。结果是：很多商业数据库遇到不支持的查询模式时，直接拒绝创建增量维护的物化视图。 PostgreSQL 的 REFRESH MATERIALIZED VIEW 至今是全量重算，不做增量维护。Oracle 的 FAST REFRESH 只支持一个有限的查询子集。\n问题二：嵌套视图。 如果视图 V2 引用了视图 V1（CREATE VIEW v2 AS SELECT ... FROM v1 JOIN ...），增量维护需要跨视图推导——V1 的变更如何传播到 V2。每增加一层嵌套，推导复杂度都在增长。\n问题三：迭代。 传统 IVM 完全不处理迭代计算。如果你的查询涉及递归 CTE 或图算法，IVM 无能为力。\nDifferential Dataflow 的方法 Differential dataflow 采取了一种完全不同的路径：不为每种查询推导特定的增量规则，而是让每个算子自己具备增量化能力。\n因为增量化是在算子层面实现的，任意算子的任意组合都自动支持增量计算。你不需要为新的查询模式写新的增量维护规则。外连接？用 join + negate 组合。子查询？展开为 join 和 reduce 的组合。递归？用 iterate。所有情况都被统一框架覆盖。\n这种通用性的代价是 arrangement 的内存开销——join 和 reduce 需要维护输入数据的索引。传统 IVM 如果只处理简单查询，可能不需要这些索引。但考虑到 differential dataflow 带来的通用性和正确性保证（尤其是对复杂查询和迭代计算的支持），这个代价在大多数场景下是值得的。\n与流处理引擎（Flink）的对比 Differential dataflow 和 Flink 这样的流处理引擎在表面上有很多相似之处——都处理变更流，都有有状态的算子（join、agg），都支持 retraction。但两者的底层模型有根本区别。\n相似之处 对于简单的流式pipeline（filter → join → agg，不涉及迭代），两者的行为确实非常接近：\n都是\u0026quot;变更进来，变更出去\u0026quot;——每个算子收到输入变更后，更新自己的状态，产出输出变更 有状态算子（join、agg）都需要维护索引——Flink 叫 state（存在 RocksDB 或堆内存中），DD 叫 arrangement 都支持撤回——Flink 用 +I/-D/+U/-U 消息类型，DD 用 $(data, time, diff)$ 中的 diff 正负 如果你只做简单的流式 ETL，两者的效果差别不大。\n区别一：批量处理 vs 逐条处理 Flink 的模型是 per-record 的——每条记录到达时立即触发一次 state 查找、一次 state 更新、一次输出。100 条变更 = 100 次独立的 state 操作。\nDD 的模型天然面向变更的集合。同一个时间戳上的所有变更构成一个 batch，arrangement 的数据结构围绕 batch 设计——新变更形成一个按 $(key, value, time)$ 排序的有序段，查找时可以批量做归并扫描，对缓存更友好。Compaction 也是 batch 级的操作。\nFlink 后来在 Table API 中加了 mini-batch 优化（table.exec.mini-batch.enabled），可以缓冲记录后批量处理，但这是在 per-record 模型上的补丁，不是基础设计。\n区别二：Delta Join 这是 arrangement 共享带来的关键能力。\n考虑一个三路 join：A ⋈ B ⋈ C。\nFlink 的做法（linear join）：级联两个二路 join。先算 A ⋈ B 得到中间结果 AB，再算 AB ⋈ C。处理 A ⋈ B 的算子看不到 C 的 state，处理 AB ⋈ C 的算子看不到 A 和 B 各自的 state——因为 Flink 的 state 是 per-operator 私有的。中间结果 AB 可能很大，需要额外的存储和维护开销。\nDD 的做法（delta join）：不维护中间结果。当 A 有变更 $\\Delta A$ 时，直接用 $\\Delta A$ 探测 B 的 arrangement，得到的结果再探测 C 的 arrangement，一步到位算出最终增量 $\\Delta A \\bowtie B \\bowtie C$。当 B 变更时，$A \\bowtie \\Delta B \\bowtie C$。当 C 变更时，$A \\bowtie B \\bowtie \\Delta C$。\nDelta join 之所以可行，依赖两个前提：\nArrangement 共享——$\\Delta A$ 的处理路径需要同时访问 B 和 C 的 arrangement，DD 的 arrangement 是跨算子共享的 多版本查询——探测 arrangement 时需要回答\u0026quot;在时间 $t$ 时 B 中有哪些数据\u0026quot;，arrangement 的多版本结构支持这种查询 在实际系统中，一个涉及 5 张表的 join 查询，delta join 只需要共享 5 张基表的 arrangement，不维护任何中间结果。同样的查询在 Flink 中需要 4 级级联 join，每级都有自己的中间状态。\n区别三：迭代 这是最根本的功能差异。DD 的偏序时间戳（继承自 timely dataflow 的 $(epoch, \\langle iteration \\rangle)$ 结构）使得系统可以在有环图中精确追踪进度，支持迭代计算的增量更新——输入变了，不需要从头迭代，只重新处理受影响的部分。\nFlink 的时间是全序的，没有 timely dataflow 那套基于 pointstamp 的进度追踪。Flink 的 iterate() 只是一条简单的反馈边，无法做到\u0026quot;输入变了，迭代增量更新\u0026quot;。正如 timely dataflow 的作者 Frank McSherry 在 GitHub 上的回复 中所说：Flink 的迭代限制是设计层面的（\u0026ldquo;absolutely part of their design\u0026rdquo;），不是工程上还没做完。\n小结 Differential dataflow 的核心贡献是一个简洁而强大的抽象：将数据表达为 $(data, time, diff)$ 三元组构成的变更流，在此之上实现通用的增量计算。\n它建立在 timely dataflow 之上：\nTimely dataflow 提供了精确的进度追踪——知道\u0026quot;什么时候一个时间点的所有变更都已处理完\u0026quot; Differential dataflow 在此基础上实现了增量语义——\u0026ldquo;输入变化时，只计算变化的部分\u0026rdquo; 两者配合的结果是：你可以像写批处理查询一样组合算子（map、filter、join、reduce），系统自动将其增量化执行。输入数据的任意变更（插入、删除、修改）会自动且正确地传播到最终输出。\n但对于大多数用户来说，直接写 differential dataflow 程序还是太底层了。人们更熟悉 SQL，更习惯通过 CREATE MATERIALIZED VIEW 来表达持续更新的查询。\n能不能在 differential dataflow 之上搭建一个 SQL 数据库，让用户用标准 SQL 来表达查询，系统自动将其编译为增量维护的 dataflow？\n这就是下一篇文章的主题：Materialize：用 Differential Dataflow 构建实时 SQL 数据库。\n","permalink":"https://luoyuxia.github.io/posts/differential-dataflow%E8%AE%A9%E8%AE%A1%E7%AE%97%E5%8F%AA%E5%81%9A%E5%A2%9E%E9%87%8F/","summary":"解读 Differential Dataflow 的核心思想：如何将数据表达为变更流，让任意关系代数运算都能增量化执行。这是三篇系列文章的第二篇。","title":"Differential Dataflow：让计算只做增量"},{"content":" 论文：Naiad: A Timely Dataflow System 作者：Derek G. Murray, Frank McSherry, Rebecca Isaacs 等（Microsoft Research） 发表：SOSP 2013（Best Paper Award）\n这是系列文章的第一篇。整个系列将沿着一条技术演进路线展开：\nTimely Dataflow（本篇）：用一个支持有环图的数据流模型，统一 batch、streaming 和 iterative 三种计算范式 Differential Dataflow：如何在 timely dataflow 之上实现通用的增量计算 Materialize：如何用 dataflow 引擎构建一个实时 SQL 数据库 这些内容最初是三年前学习的，大概是 Flink 和 RisingWave 的口水战期间，出于好奇去读了 Naiad 和 Differential Dataflow 的论文。最近花了点时间重新整理，发上来做个记录。\n为什么需要 Timely Dataflow 2013 年前后，大数据处理领域有三条清晰的技术路线，但它们各自只能覆盖一种计算范式：\n批处理系统（MapReduce、Spark）：将数据分成批次处理，擅长大规模离线计算，但延迟在秒甚至分钟级别。即使只有一条新数据到来，也要等到一整批数据凑齐才能处理。\n流处理系统（Storm、S4）：逐条处理数据，延迟低，但计算模型受限。最大的问题是它们难以支持迭代计算——图计算中的 PageRank、机器学习中的梯度下降都需要反复迭代直到收敛，而纯流处理系统的 DAG（有向无环图）不支持这种循环结构。\n图计算系统（Pregel、GraphLab）：专门做迭代，但它们不擅长流处理和增量更新，迭代之间需要全局同步。\n最终，实际生产中的数据管道往往变成这样：用 Storm 做实时预处理，用 Spark 做批量聚合，用 Pregel 做图计算——三套系统，三种编程模型，三组运维负担，还要处理它们之间的数据一致性。\nNaiad 的目标很直接：用一个统一的计算模型，同时支持 batch、streaming 和 iterative 计算，且延迟足够低。\n这个统一模型就是 timely dataflow。\nTimely Dataflow 计算模型 有向图，但可以有环 Dataflow 计算模型本身并不新鲜——计算被表达为一个有向图，节点是算子（operator），边是数据通道。数据从输入进入图，经过各个算子处理后，从输出流出。MapReduce 就是一种最简单的 dataflow：两个节点（Map 和 Reduce），一条边。\n但传统 dataflow 系统要求图是无环的（DAG），这使得它们无法自然表达迭代计算。如果你想做 PageRank，只能在外部写一个循环，每次迭代手动将上一轮的输出喂回输入。\nTimely dataflow 的核心区别是：它允许图中存在环（cycle）。迭代计算直接在图内完成——一个循环结构中的数据反复流过同一组算子，直到满足收敛条件后从循环中流出。\n三种计算范式如何统一 有环图看起来只是一个小扩展，但它改变了模型的表达能力。下面来看三种范式如何自然映射到同一个 timely dataflow 图：\nBatch（批处理）：一批数据作为一个 epoch 注入 dataflow 图。所有数据流过算子、完成处理后，系统输出这个 epoch 的完整结果。没有环，没有迭代，就是一次性执行的 DAG。等价于 MapReduce 或 Spark 的一次 job。\nStreaming（流处理）：数据持续到来，每条（或每小批）数据作为一个新的 epoch 注入。Dataflow 图始终在运行，新数据到来时立即处理。不同 epoch 的数据可以在图中流水线式地并行处理——epoch 5 的数据在算子 A 处理的同时，epoch 4 的数据已经在算子 B 了。\nIterative（迭代计算）：数据在图的循环结构中反复流转。每一轮迭代对应循环计数器加 1。当一轮迭代不再产生新数据（收敛了），数据从循环中流出。无需像 BSP 模型那样在每轮之间做全局同步。\n这不是三种模式被硬塞进一个框架，而是它们本质上就是同一种计算模型在不同拓扑结构下的表现。 DAG 就是没有环的特殊情况，batch 就是只有一个 epoch 的特殊情况。\n但这种统一带来了一个根本性的难题。\n核心难题：什么时候算完了？ 在 DAG 中，进度追踪是直觉化的——如果一个算子上游的所有消息都已到达，那它就可以安全地结束当前批次的处理。因为没有环，消息只会往一个方向流动，不会回头。\n一旦有了环，情况就完全不同了。考虑一个迭代计算：算子 A 的输出经过算子 B 后可能会流回算子 A。那 A 怎么知道\u0026quot;时间 t 的所有消息已经到齐了\u0026quot;？B 可能还在产生新的消息，这些消息又会回到 A。而 A 的处理又可能触发 B 产生更多消息……\n在有环图中，一个算子无法通过简单计数来判断自己是否收到了所有消息。 解决这个问题，是 Naiad 能够真正统一三种范式的前提——如果你无法判断某个时间点的计算是否完成，你就无法正确地输出结果，无法判断迭代是否收敛，也无法让不同 epoch 安全地并行。\nNaiad 的回答是：一套精确的进度追踪协议。\n时间戳设计 为了在有环图中追踪进度，Naiad 设计了一种层级化的时间戳结构：\n$$t = (e, \\langle c_1, c_2, \\ldots, c_k \\rangle)$$ $e$ 是 epoch，表示第几批输入数据。你往系统里灌了三批数据，分别是 epoch 1、2、3。Epoch 是外部输入的逻辑时间，和迭代无关。 $c_1, c_2, \\ldots, c_k$ 是循环计数器，表示这批数据在循环中的第几轮迭代。每个循环嵌套对应一个计数器。 例如，一个没有循环的 dataflow 中，时间戳就是简单的 $(e)$。如果有一层循环嵌套，时间戳变成 $(e, \\langle c_1 \\rangle)$，表示\u0026quot;第 $e$ 批输入数据，在循环中的第 $c_1$ 轮迭代\u0026quot;。\n关键点：这些时间戳之间的关系是偏序（partial order），不是全序。\n什么意思？全序意味着任意两个元素都可以比较大小，就像自然数：3 一定小于 5。但偏序允许两个元素不可比较。\n时间戳的比较规则是：$(e_1, \\langle c_1 \\rangle) \\leq (e_2, \\langle c_2 \\rangle)$ 当且仅当 $e_1 \\leq e_2$ 且 $c_1 \\leq c_2$——每个分量都必须 $\\leq$。来看几个例子：\n$(1, \\langle 3 \\rangle) \u003c (1, \\langle 5 \\rangle)$：同一个 epoch，迭代 3 在迭代 5 之前。两个分量都 $\\leq$，可比较。 $(1, \\langle 3 \\rangle) \u003c (2, \\langle 5 \\rangle)$：epoch $1 \u003c 2$，迭代 $3 \u003c 5$，两个分量都 $\\leq$，可比较。 $(1, \\langle 5 \\rangle)$ 和 $(2, \\langle 3 \\rangle)$：epoch $1 \u003c 2$，但迭代 $5 \u003e 3$。两个分量方向相反，谁也不\u0026quot;先于\u0026quot;谁，不可比较。 最后这种不可比较的情况正是偏序的意义所在——epoch 1 的第 5 轮迭代和 epoch 2 的第 3 轮迭代是独立的计算，系统可以并行处理它们，不需要等一个完成后再做另一个。这种跨 epoch 的流水线并行，是 Naiad 实现低延迟的关键来源之一。\n进度追踪协议 进度追踪协议（Progress Tracking Protocol）是 Naiad 论文最核心的技术贡献。它回答了那个根本问题：在有环图中，一个节点如何知道\u0026quot;时间 t 的所有消息都已到达\u0026quot;？\nPointstamp 协议的基本概念是 pointstamp，它是一个二元组：\n$$\\text{pointstamp} = (\\text{timestamp}, \\text{location})$$ timestamp 是上面描述的 $(e, \\langle c_1, c_2, \\ldots \\rangle)$ 形式的时间戳 location 是图中的一个位置（某条边或某个节点） 一个 pointstamp $(t, l)$ 表示\u0026quot;在位置 $l$ 上，还可能出现时间戳为 $t$ 的消息\u0026quot;。系统追踪的就是所有活跃的 pointstamp。\nCould-Result-In 关系 协议的核心机制是 could-result-in 关系。给定两个 pointstamp $(t_1, l_1)$ 和 $(t_2, l_2)$，如果 $(t_1, l_1)$ could-result-in $(t_2, l_2)$，意味着位置 $l_1$ 上时间戳为 $t_1$ 的消息经过处理后，有可能在位置 $l_2$ 产生时间戳为 $t_2$ 的消息。\n这个关系由图的拓扑结构决定：\n普通边：消息沿边传递，时间戳不变。所以如果 $l_1 \\to l_2$ 是一条普通边，那么 $(t, l_1)$ could-result-in $(t, l_2)$。 进入循环的边（ingress）：时间戳增加一个新的循环计数器维度，初始值为 0。$(e, l_1)$ could-result-in $((e, \\langle 0 \\rangle), l_2)$。 循环反馈边（feedback）：循环计数器加 1。$((e, \\langle c \\rangle), l_1)$ could-result-in $((e, \\langle c+1 \\rangle), l_2)$。 离开循环的边（egress）：移除最内层的循环计数器维度。$((e, \\langle c \\rangle), l_1)$ could-result-in $(e, l_2)$。 通过沿着图的路径传递这些关系，系统可以判断：从任意 pointstamp 出发，未来可能产生哪些 pointstamp。\nFrontier（前沿） 有了 could-result-in 关系，系统维护一个全局的 frontier。直观地说，frontier 表示\u0026quot;时间线上还没有完成的最早的那些时间戳\u0026quot;。\n更精确地说，系统中每个位置都有一个 frontier——一个时间戳的集合（称为 antichain），满足：\n集合中任意两个时间戳互不可比较（都不是对方的\u0026quot;之前\u0026quot;） 任何未来可能出现的消息，其时间戳一定 $\\geq$ 集合中的某个时间戳 当一个节点在位置 $l$ 上看到 frontier 推进到了 $t$，它就知道：不会再有时间戳 $\u003c t$ 的消息到达 $l$。这时候，它可以安全地处理并输出时间 $t$ 之前的结果。\nNotification（通知） Naiad 的算子可以请求在特定时间戳收到 notification（通知）。当 frontier 推进到某个时间戳之后，对应的通知被触发，告诉算子：\u0026ldquo;时间 $t$ 的所有输入都已到齐，你可以产出最终结果了。\u0026rdquo;\n例如，一个 count 算子需要等到时间 $t$ 的所有输入消息到齐后，才能输出该时间戳下的计数结果。它请求一个时间 $t$ 的通知，当收到通知时，输出 $(t, \\text{count})$。\n协议的运作方式 上面介绍了 pointstamp、could-result-in、frontier、notification 这些概念，但它们如何协同工作？让我们用一个具体的例子来看整个协议是如何运作的。\n考虑一个带单层循环的 dataflow：算子 A 在循环中做迭代计算，每轮处理完后，一部分结果退出循环输出，一部分通过 Feedback 送回 A 继续迭代：\n1 2 3 4 5 6 epoch 1 (1,⟨0⟩) epoch 1 Input ──────────→[Ingress]─────→ [A] ────→[Egress]────────→ Output ↑ | (1,⟨c+1⟩) (1,⟨c⟩) | ↓ [Feedback] 图中标注了时间戳在各条边上如何变化：Ingress 为时间戳添加循环计数器维度，epoch 1 变为 $(1,\\langle 0 \\rangle)$；Feedback 将循环计数器加 1，$(1,\\langle c \\rangle)$ 变为 $(1,\\langle c+1 \\rangle)$；Egress 移除循环计数器维度，$(1,\\langle c \\rangle)$ 变为 epoch 1——循环外不再需要迭代信息。\n协议追踪每个 pointstamp 的活跃计数（occurrence count），有两种东西会贡献计数：\n未消费的消息：一条时间戳为 $t$ 的消息停留在边 $l$ 上，pointstamp $(t, l)$ 的计数 +1。 Capability（能力声明）：算子持有的一个令牌，表示\u0026quot;我还可能在时间戳 $t$ 上产生消息\u0026quot;，对应 pointstamp 的计数 +1。 为什么需要 capability？因为算子的行为对系统来说是黑盒——系统不知道一个算子收到消息后会不会产生输出、什么时候产生、产生在哪个时间戳上。所以系统需要算子主动声明自己的意图。\n具体来说，当算子收到一条时间戳为 $t$ 的消息时，它同时获得一个时间戳为 $t$ 的 capability。只要它持有这个 capability，系统就认为\u0026quot;该算子可能在时间戳 $t$（或更晚的时间戳）上产生新消息\u0026quot;，因此不会推进 frontier。算子可以做三种操作：\n持有：继续持有 capability，阻止 frontier 推进——比如一个聚合算子需要等齐同一时间戳的所有消息后才能输出，在此期间它必须持有 capability。 降级（downgrade）：将 capability 的时间戳推进到更晚的值。这告诉系统\u0026quot;我不会再在原来的时间戳上产生消息了，但可能在更晚的时间戳上产生\u0026quot;。系统可以推进原时间戳的 frontier。 释放（drop）：彻底放弃 capability，告诉系统\u0026quot;我不会再在这个时间戳上产生任何消息了\u0026quot;。对应 pointstamp 的活跃计数 -1。 当一个 pointstamp 的活跃计数归零——既没有未消费的消息，也没有算子持有对应的 capability——系统就知道这个 pointstamp 不会再有新的事件了。\n逐步追踪 下面追踪 epoch 1 的数据在这个 dataflow 中的完整生命周期。为了聚焦核心机制，我们省略 Input 和 Output 的细节，从数据进入循环开始。\n第 1 步：数据进入循环。\nInput 发送 3 条 epoch 1 的数据后，宣布\u0026quot;不会再发送 epoch 1 的数据\u0026quot;（即 Input 将自己的 capability 推进到 epoch 2）。Ingress 将这 3 条消息的时间戳从 epoch 1 转换为 $(1,\\langle 0 \\rangle)$，发往 A。\n此时系统中的活跃 pointstamp：\n位置 时间戳 类型 计数 Ingress → A 边 $(1,\\langle 0 \\rangle)$ 消息 3 A 还没有开始处理，也没有持有任何 capability。3 条消息停留在 A 的输入边上，每条贡献 1 个活跃计数。\n第 2 步：A 开始处理第 0 轮。\nA 从输入边取走 3 条消息（边上的活跃计数 -3），同时获得时间戳 $(1,\\langle 0 \\rangle)$ 的 capability（A 节点上的活跃计数 +1）。这个 capability 表示 A 还可能在 $(1,\\langle 0 \\rangle)$ 上产生输出。\n位置 时间戳 类型 计数 算子 A $(1,\\langle 0 \\rangle)$ capability 1 注意：边上的 3 条消息消失了，但 A 的 capability 保持着活跃计数。从系统的角度看，$(1,\\langle 0 \\rangle)$ 仍然\u0026quot;活着\u0026quot;——A 随时可能产生新的消息。\n第 3 步：A 产生输出。\nA 处理完 3 条消息后，决定：\n2 条已收敛，发往 Egress（时间戳仍为 $(1,\\langle 0 \\rangle)$） 1 条需要继续迭代，发往 Feedback（时间戳仍为 $(1,\\langle 0 \\rangle)$） 发送完毕后，A drop 掉 $(1,\\langle 0 \\rangle)$ 的 capability——它已经完成了这个时间戳上的所有工作。\n位置 时间戳 类型 计数 A → Egress 边 $(1,\\langle 0 \\rangle)$ 消息 2 A → Feedback 边 $(1,\\langle 0 \\rangle)$ 消息 1 A 的 capability 没了，A 上的活跃计数归零。那 $(1,\\langle 0 \\rangle)$ 是否彻底完成了？还不行——边上还有 3 条消息。\n第 4 步：Feedback 转发，时间戳递增。\nFeedback 取走边上的 1 条消息（A → Feedback 边上的计数 -1），将时间戳从 $(1,\\langle 0 \\rangle)$ 变为 $(1,\\langle 1 \\rangle)$，发到 A 的输入边。\n位置 时间戳 类型 计数 A → Egress 边 $(1,\\langle 0 \\rangle)$ 消息 2 Feedback → A 边 $(1,\\langle 1 \\rangle)$ 消息 1 这是一个关键时刻。循环内部所有 $(1,\\langle 0 \\rangle)$ 的活跃计数都归零了（A 的 capability 已 drop，Feedback 边上的消息已取走）。唯一剩下的 $(1,\\langle 0 \\rangle)$ 是 Egress 输入边上的 2 条消息——但 Egress 是循环的出口，不会将数据送回 A。\n系统现在检查 could-result-in：还有没有任何活跃 pointstamp，能在 A 处重新产生 $(1,\\langle 0 \\rangle)$？\n逐一排查所有到达 A 的路径：\nInput → Ingress → A？Input 已推进到 epoch 2，Ingress 不会再产生 $(1,\\langle 0 \\rangle)$。 Feedback → A？Feedback 的变换规则是 $\\langle c \\rangle \\to \\langle c+1 \\rangle$。目前 Feedback → A 边上唯一的消息是 $(1,\\langle 1 \\rangle)$，它经过 A 处理后，如果再反馈，只会变成 $(1,\\langle 2 \\rangle)$、$(1,\\langle 3 \\rangle)$……永远回不到 $\\langle 0 \\rangle$。 结论：$(1,\\langle 0 \\rangle)$ 在 A 处永远不会再出现。A 的 frontier 从 $\\{(1,\\langle 0 \\rangle)\\}$ 推进到 $\\{(1,\\langle 1 \\rangle)\\}$。\n第 5 步：第 1 轮迭代。\nA 取走 Feedback → A 边上的 1 条 $(1,\\langle 1 \\rangle)$ 消息，获得 $(1,\\langle 1 \\rangle)$ 的 capability。处理后发现这条数据也收敛了，发往 Egress，不再发送任何消息到 Feedback。A drop 掉 $(1,\\langle 1 \\rangle)$ 的 capability。\n位置 时间戳 类型 计数 A → Egress 边 $(1,\\langle 0 \\rangle)$ 消息 2 A → Egress 边 $(1,\\langle 1 \\rangle)$ 消息 1 循环内其他位置 — — 全部为 0 循环内没有任何活跃 pointstamp 了。 A 没有 capability，Feedback 边上没有消息，Ingress 不会再送入数据。没有任何东西能在循环内产生新的 pointstamp。\nFrontier 推进到 epoch 1 之后。 Egress 收到 notification：epoch 1 的所有迭代已完成，可以安全地输出 3 条最终结果（2 条来自迭代 0，1 条来自迭代 1）。\n两个关键观察 系统不需要预先知道会迭代几轮。 它不关心\u0026quot;迭代是否收敛\u0026quot;这个语义问题——它只追踪 pointstamp 的活跃计数。当某轮迭代不再产生反馈消息时，Feedback 边上的活跃计数自然归零，frontier 自然推进。迭代收敛是进度追踪的结果，而不是需要额外检测的条件。\nFeedback 边的时间戳递增是整个机制的关键。 正是因为 Feedback 将 $\\langle c \\rangle$ 变为 $\\langle c+1 \\rangle$，系统才能区分\u0026quot;第 0 轮的消息\u0026quot;和\u0026quot;第 1 轮的消息\u0026quot;。如果 Feedback 不改变时间戳，$(1,\\langle 0 \\rangle)$ 的消息经过 Feedback 后还是 $(1,\\langle 0 \\rangle)$——could-result-in 关系会形成环，系统就永远无法判断 $(1,\\langle 0 \\rangle)$ 是否已经处理完，因为\u0026quot;总是可能还有新的 $(1,\\langle 0 \\rangle)$ 从 Feedback 回来\u0026quot;。\n分布式实现 论文中的实际实现是分布式的——没有集中式协调器，而是每个 worker 维护一份全局 pointstamp 计数的副本。算子每次变更活跃计数（消费消息、发送消息、获取或 drop capability），都将变更广播给所有 worker。每个 worker 独立地根据汇总后的计数和 could-result-in 关系计算 frontier，判断哪些 notification 可以发出。\n对比：为什么不用全局 Barrier？ 在 Naiad 之前，处理迭代计算最常见的方法是 BSP（Bulk Synchronous Parallel） 模型，也就是 Pregel 的做法。BSP 在每轮迭代之间插入一个全局 barrier：所有 worker 必须完成当前轮次后，才能进入下一轮。\n这意味着什么？最慢的 worker 决定整体速度。 即使 99% 的节点在迭代 5 就已经收敛了，也必须等待那 1% 的慢节点完成迭代 5 后才能一起进入迭代 6。\n更进一步，BSP 是一个\u0026quot;一次性计算\u0026quot;的模型：加载数据，迭代到收敛，输出结果——没有\u0026quot;持续接收新输入\u0026quot;的概念。如果有新数据到来（比如图中新增了一条边），你只能等当前计算结束后重新启动一轮新的计算。这不是 barrier 的限制，而是模型本身就不支持在计算过程中注入新数据。\nNaiad 的模型从一开始就不同。它将\u0026quot;输入批次\u0026quot;（epoch）和\u0026quot;迭代轮数\u0026quot;（循环计数器）编码到同一个时间戳中，使得系统可以在迭代的同时接收新的输入。Pointstamp 追踪是细粒度的——每个算子在每个时间戳上独立追踪进度。已经收敛的节点不需要等待未收敛的节点。当 epoch 1 的数据还在做第 3 轮迭代时，epoch 2 的数据已经可以进入第 0 轮迭代。$(1,\\langle 3 \\rangle)$ 和 $(2,\\langle 0 \\rangle)$ 是偏序下不可比较的两个时间戳，系统知道它们之间没有依赖关系，可以并行处理。\n这种细粒度追踪不只是性能优化，更是正确性保证。在有环图中，如果没有精确的进度追踪：\n你可能过早地认为\u0026quot;已经收齐了时间 t 的所有消息\u0026quot;，从而产生错误的输出 你可能过于保守地等待，导致不必要的延迟 你可能完全无法判断迭代是否已收敛 Naiad 的 could-result-in 关系从图的拓扑结构出发，数学上保证了：如果系统说\u0026quot;时间 t 之前不会再有新消息\u0026quot;，那就一定不会有。这个保证既不过于激进（不会遗漏消息），也不过于保守（不会做不必要的等待）。\n论文中的性能数据 论文的实验验证了这种设计在实际场景中的效果。在一个运行迭代图计算（如 weakly connected components）的实验中，Naiad 在处理实时输入变更时的延迟在毫秒级，而同等任务上 Spark 的批处理延迟在秒级。在吞吐量方面，Naiad 在批处理模式下的性能与 Spark/DryadLINQ 相当，但在需要低延迟迭代更新的场景中优势显著——因为它不需要在每轮迭代之间做全局同步，也不需要将中间结果写入持久化存储。\n从论文到实现：Rust 版 timely-dataflow Naiad 论文的原始实现是用 C# 写的。论文作者之一 Frank McSherry 后来用 Rust 重新实现了 Naiad 的核心思想，这就是开源项目 timely-dataflow。\n值得一提的是 Rust 的所有权系统与 capability 机制的契合。在 Rust 实现中，capability 被具象化为一个 Capability\u0026lt;T\u0026gt; 类型——它是一个普通的 Rust 值，拥有所有权语义。当算子持有这个值时，对应 pointstamp 的活跃计数为正；当这个值被 drop（无论是显式释放还是离开作用域自动销毁），活跃计数自动减 1。这意味着进度追踪的正确性由编译器保证——你不可能\u0026quot;忘记释放 capability\u0026quot;，因为 Rust 的所有权机制会在值离开作用域时自动 drop 它。\n这个 Rust 实现后来成为了 Differential Dataflow 和 Materialize 的底层基础设施，也是下两篇文章的技术起点。\n小结 Timely dataflow 的核心贡献是用一个计算模型统一了三种数据处理范式。它的做法是将传统 DAG dataflow 扩展为支持有环图，使得 batch、streaming 和 iterative 计算都成为同一模型的不同实例。\n让这种统一成为可能的关键机制是进度追踪协议——基于 pointstamp 和 could-result-in 关系，系统能够在有环图中精确判断\u0026quot;什么时候某个时间点的计算已经完成\u0026quot;。没有这个机制，有环图中的迭代无法收敛，流式数据无法正确输出，不同 epoch 也无法安全地并行。\n但 timely dataflow 本身只解决了\u0026quot;何时完成\u0026quot;的问题，并不解决\u0026quot;如何避免重复计算\u0026quot;的问题。当输入数据变化时，timely dataflow 还是需要重新运行整个计算。\n有了精确的进度追踪作为基础，我们能不能更进一步——构建一套通用的增量计算框架，当输入变化时只重新计算受影响的部分？\n这正是下一篇文章的主题：Differential Dataflow：让计算只做增量。\n","permalink":"https://luoyuxia.github.io/posts/timely-dataflow%E7%94%A8%E4%B8%80%E4%B8%AA%E8%AE%A1%E7%AE%97%E6%A8%A1%E5%9E%8B%E7%BB%9F%E4%B8%80%E4%B8%89%E7%A7%8D%E6%95%B0%E6%8D%AE%E5%A4%84%E7%90%86%E8%8C%83%E5%BC%8F/","summary":"解读 Naiad 论文（SOSP 2013 Best Paper），深入分析 Timely Dataflow 如何用一个支持有环图的数据流模型，统一 batch、streaming 和 iterative 三种计算范式。这是三篇系列文章的第一篇。","title":"Timely Dataflow：用一个计算模型统一三种数据处理范式"},{"content":" 论文：Column-Stores vs. Row-Stores: How Different Are They Really? 作者：Daniel J. Abadi, Samuel R. Madden, Nabil Hachem 发表：SIGMOD 2008\n一个经久不衰的问题 在数据库领域，有一个看似简单的设计决策：数据应该按行存储还是按列存储？\n传统的关系型数据库（如 MySQL、PostgreSQL、Oracle）都是行存储（row-store）——一行数据的所有列在磁盘上连续存放。这在事务处理（OLTP）场景下非常高效，因为一次插入或查询通常涉及一整行。\n但在数据分析（OLAP）场景下，情况截然不同。一个典型的分析查询可能是：\u0026ldquo;计算 2024 年所有订单的总金额\u0026rdquo;——它只需要 order_date 和 amount 两列，却不得不把每行中其他几十列数据也从磁盘读出来。这就是巨大的 I/O 浪费。\n列存储（column-store）应运而生：同一列的数据在磁盘上连续存放，只读需要的列，天然适合分析场景。MonetDB、C-Store（后来的 Vertica）、Sybase IQ 等系统证明了列存在 OLAP 上的强大性能。\n但这就引出了一个关键问题：列存的性能优势，究竟是来自\u0026quot;只读需要的列\u0026quot;带来的 I/O 减少，还是来自列存系统中查询引擎的一系列优化技术？ 如果是前者，那么在行存数据库中也可以实现\u0026quot;只读需要的列\u0026quot;——比如把一张宽表垂直拆分成每列一张窄表，或者为每列建索引走 index-only scan 绕过原始宽表。这些方式都能避免读取不需要的列，那是不是就能达到列存的性能？\nAbadi 等人在 2008 年发表的这篇论文，用严谨的实验回答了这个问题。结论是：并不是。如果没有查询引擎上其他几个优化措施的配合，仅仅减少 I/O 并不能达到列存的性能。\n实验设置 对比系统 论文选择了两个系统进行对比：\nC-Store：MIT 开发的列存数据库原型系统，后来商业化为 Vertica。 System X：一个\u0026quot;知名商业行存数据库\u0026quot;（论文未透露具体名称），拥有位图索引、物化视图等高级特性。值得注意的是，System X 作为商业系统实际上比 C-Store 更加成熟。 基准测试 论文使用了 Star Schema Benchmark（SSBM），这是 Pat O\u0026rsquo;Neil 对 TPC-H 的改造版本，更贴近真实的数据仓库场景。它的核心是一个典型的星型模型：\n一张 事实表（lineorder）：6 亿行，约 33GB 多张 维度表（customer, supplier, part, date） SSBM 包含 13 个查询，分为 4 组（Q1-Q4），每组查询涉及的维度表数量和过滤后返回的数据量逐步递进——从简单的单表过滤到多表 join 加聚合。\n在行存上模拟列存 论文的第一个核心贡献是：在行存数据库上尝试了多种模拟列存的方案，看它们能否缩小与真正列存的差距。\n方案一：垂直分区（Vertical Partitioning） 最直觉的方式——为表的每一列都创建一个两列的窄表，每个窄表包含该列的值和行的 position。这样查询时只需读取需要的窄表，再通过 position 做 join。\n问题：\n每个窄表都要额外存储 position 列，造成存储膨胀 需要将多个窄表在 position 上做 join 来重构行，带来额外开销 行存储引擎中每行的 tuple header 开销（在 System X 中每行约 24 字节）在窄表中被放大——原本一行摊一次的 header 开销，现在每列都要摊一次 方案二：只用索引（Index-Only Plans） 为每一列创建一个索引，包含 \u0026lt;record-id, value\u0026gt;。查询时通过\u0026quot;仅索引扫描\u0026quot;（index-only scan）直接从索引中获取列数据，绕过主表。\n问题：\n索引中每个条目仍然带有指向原始行的 record-id（每个 6 字节）以及索引页的元数据开销 当需要从多个索引中获取数据时，仍需在 record-id 上做 join 索引虽然带来了一定程度的有序性，但并非专门为列式扫描优化 方案三：物化视图（Materialized Views） 为每个查询预先创建一个只包含所需列的物化视图——将要查询的列直接变成一张物化视图，这样可以直接访问指定的列，也没有上面两种方法显式存储 position/record-id 的开销。\n问题：\n需要为每个查询预先创建视图，现实中不可行 即便如此，仍然存在 tuple header 等行存固有开销 这实际上是行存能做到的最理想情况——它假设我们提前知道所有查询，现实中不可能为每个查询都定制一个物化视图 小结 列存的四大关键优化 论文的第二个核心贡献是：识别并量化了列存系统中四项关键优化技术各自的性能贡献。\n1. 压缩（Compression） 列存天然适合压缩，因为同一列的数据类型相同、值域相近，相邻值之间往往高度相似。压缩带来两个层面的好处：\n减少 I/O：不管是从硬盘把数据读入内存，还是从内存把数据读入 CPU cache，更小的数据量意味着更少的传输。\n直接在编码后的数据上计算：比如采用 RLE 编码，1, 1, 1, 2, 2 被编码为 1×3, 2×2。计算 sum 时，执行引擎可以直接在压缩后的数据上计算（1*3 + 2*2 = 7），而不需要先解压再计算。这是行存难以复制的——因为行存的压缩是以行为单位，无法在压缩状态下做列级操作。\n值得注意的是，一般列排序后才会有更高的压缩率——排序使得相邻值更容易重复，RLE 等编码的效果更好。\n2. 延迟物化（Late Materialization） 什么是物化 为了能够把底层存储格式（面向 Column 的）跟用户查询表达的意思（Row）对应上，在一个查询的生命周期的某个时间点，一定要把数据转换成 Row 的形式，这在 Column-Store 里面被称为物化（Materialization）。\n延迟物化就是把这个物化的时机尽量拖延到整个查询生命周期的后期。\n以一个具体的查询为例：\n1 2 3 4 SELECT name FROM person WHERE id \u0026gt; 10 AND age \u0026gt; 20 早期物化（Naive 做法）：从文件系统读出 id、age、name 三列的数据，马上物化成一行行的 person 数据，然后应用两个过滤条件 id \u0026gt; 10 和 age \u0026gt; 20，过滤完之后从数据里面抽出 name 字段作为最终结果。\n延迟物化的做法：先不拼出行式数据，直接在 Column 数据上分别应用两个过滤条件，得到满足条件的两个 bitmap，然后两个 bitmap AND 一下，得到同时满足两个条件的最终 bitmap；最后再拿着这个 bitmap 对 name 字段进行提取，得到最终结果。\n延迟物化的优点 减少了要拼接的列：避免了不必要的物化开销。如果第一个过滤条件已经排除了 90% 的行，后续只需处理 10% 的数据。 保留压缩优势：进行物化需要将列进行解压缩，这样之前提到的直接在压缩后数据上计算的优势就没了。延迟物化让数据尽量保持在压缩态。 Cache 友好：不会因为不需要的列污染了 Cache line。 保留 Block Iteration 的优势：如果早期物化把多列拼成行，一行里混合了 int、varchar、double 等不同类型，整行长度不固定，无法当作数组进行批量处理。而延迟物化让数据保持在列格式下——同一列的类型相同（比如 age 列全是 4 字节的 int），可以当作定长数组访问，Block Iteration 和 SIMD 才能发挥作用。 3. 块式迭代（Block Iteration） 传统行存的执行引擎使用\u0026quot;一次一行\u0026quot;（tuple-at-a-time）的迭代器模型：对于每条数据，都要从 Row 数据里面抽取出需要的 column，然后调用相应的函数去处理。\n列存使用块式迭代——将数据进行攒批操作，一次性处理多条数据：\n函数调用次数大幅降低 如果列是定长的，就可以以数组的方式对数据进行访问，从而利用现代 CPU 的 SIMD（Single Instruction Multiple Data）指令实现并行化执行 数据在内存中连续排列，对 CPU 缓存极为友好 虽然行存也可以实现块式迭代（一次传递多行），但列存的块式迭代更加高效，因为传递的是同一类型的值数组，数据是定长的可能性更大，更容易利用 SIMD 优化。\n小结 4. Invisible Join（该论文提出的新技术） SSBM 的查询涉及大量星型 schema 上的 join：事实表与多张维度表做等值连接。在讨论 invisible join 之前，先看两种传统的 join 方案。\n传统方案一：按 Selectivity 排序 Join 按过滤条件的选择性从大到小对表进行 join。例如：\n1 2 3 4 SELECT ... FROM customer AS c, lineorder AS lo WHERE lo.custkey = c.custkey AND c.region = \u0026#39;ASIA\u0026#39; 因为 c.region = 'ASIA' 能过滤更多的数据，所以先对 customer 和 lineorder 进行 join，然后通过 c.region 进行 filter。以此类推处理其他维度表。\n问题：一开始就做了 JOIN，享受不了延迟物化的各种优化。\n传统方案二：延迟物化的 Join 用延迟物化的思路来做 join——先不进行 JOIN，而是先过滤，再用 position 来延迟提取数据。用一个具体例子来说明，假设查询为：\n1 2 3 4 SELECT c.nation, lo.revenue FROM customer AS c, lineorder AS lo WHERE lo.custkey = c.custkey AND c.region = \u0026#39;ASIA\u0026#39; 第一步：在维度表上过滤，拿到满足条件的 position 和主键。\ncustomer 表：\npos custkey nation region 0 1 USA AMERICA 1 2 CHINA ASIA 2 3 FRANCE EUROPE 3 4 JAPAN ASIA 4 5 INDIA ASIA 过滤 region = 'ASIA' 后，得到满足条件的 position：{1, 3, 4}，对应的主键 custkey：{2, 4, 5}。\n第二步：用维度表的主键跟事实表的外键做 join，得到 position pair。\nlineorder 事实表：\npos custkey revenue 0 5 100 1 1 200 2 2 300 3 4 400 4 3 500 扫描事实表的 custkey 列，看哪些能和 {2, 4, 5} join 上，得到 position pair：\n事实表 pos custkey 维度表 pos 0 5 4 2 2 1 3 4 3 第三步：用 position pair 提取最终数据。\n从事实表提取 lo.revenue：按事实表 pos {0, 2, 3} 访问 → 顺序基本有序 → 100, 300, 400 从维度表提取 c.nation：按维度表 pos {4, 1, 3} 访问 → 乱序跳跃 → INDIA, CHINA, JAPAN 问题就在这里：提取维度表列值时，访问顺序是 pos 4, 1, 3——完全是随机跳跃的。如果有多个维度表都需要提取列值，每个维度表都会有这样的随机 I/O，性能很差。\nInvisible Join：延迟物化 + 最小化乱序 Invisible join 的核心思想是将 join 看作是在 join 列上的过滤。沿用上面同一组数据，看 invisible join 怎么做：\n1 2 3 4 SELECT c.nation, lo.revenue FROM customer AS c, lineorder AS lo WHERE lo.custkey = c.custkey AND c.region = \u0026#39;ASIA\u0026#39; 阶段一：在维度表上 apply 过滤条件，构建 hash table。\n过滤 customer 表 region = 'ASIA'，得到满足条件的 custkey 集合 {2, 4, 5}，构建 hash table。\n阶段二：扫描事实表外键列，生成 bitmap。\n顺序扫描 lineorder 的 custkey 列，逐个探测 hash table，生成 bitmap：\n事实表 pos custkey 在 hash table 中? bitmap 0 5 YES 1 1 1 NO 0 2 2 YES 1 3 4 YES 1 4 3 NO 0 得到事实表的 bitmap：[1, 0, 1, 1, 0]。\n如果有多个维度表（如还有 supplier、date），每个维度表独立产生一个 bitmap，最后所有 bitmap 做 AND 运算，得到同时满足所有条件的最终 bitmap。\n阶段三：用最终 bitmap 提取数据。\nbitmap 为 1 的事实表行是 {0, 2, 3}：\n提取 lo.revenue：按事实表 pos {0, 2, 3} 顺序访问 → 100, 300, 400 ✓ 顺序访问 提取 lo.custkey：按事实表 pos {0, 2, 3} 顺序访问 → 5, 2, 4 ✓ 顺序访问 用 custkey 值去 customer 表查找 c.nation：nation[5]=INDIA, nation[2]=CHINA, nation[4]=JAPAN 最后一步对维度表的访问，如果维度表的主键是从 1 开始的连续整数（这是 common case），那么用 custkey 值直接作为数组下标访问 → 快速的 array lookup。\n对比传统方案二，为什么 invisible join 更好？\n两者对维度表的访问模式其实类似——都需要用某种索引去维度表取值。invisible join 的优势不在于单次访问更快，而在于整体流程的改进：\n传统延迟物化 Join Invisible Join 中间结果 需要存储和维护 position pair 只需一个 bitmap，更紧凑 多维度场景 每个维度依次 join，每步都产生 position pair 并做随机提取 各维度独立生成 bitmap，AND 后一次性提取 提取的行数 每步提取满足部分条件的行，数量多 只提取满足所有条件的行，数量少得多 核心收益在最后一点：由于所有维度的 bitmap 先做了 AND，最终要提取的行数远少于传统方案。行数少意味着维度表的相关数据更容易放进 L2 cache，即使访问模式是随机的，在 cache 内的随机访问也很快。\n流程一览 Between-Predicate Rewriting 至此，invisible join 和传统的延迟物化 join 没有本质区别——阶段二仍然是用事实表的外键去 hash table 里 probe。作者进一步提出了 between-predicate rewriting，在特定条件下完全消除 hash lookup。\n作者观察到，对于 star schema join 有一种 common case：维度表的数据在进行常量条件过滤后，满足条件的主键通常是连续的。\n沿用上面的例子。假设 date 维度表的 datekey 采用 YYYYMMDD 格式，过滤 year \u0026gt;= 1992 AND year \u0026lt;= 1997 后，满足条件的 datekey 恰好是一段连续范围 19920101 ~ 19971231：\n原来的做法（hash probe）：构建一个 hash table，插入 datekey {19920101, 19920102, \u0026hellip;, 19971231}，然后扫描事实表的 orderdate 列，每个值都去 hash table 里查一次。\nBetween-predicate rewriting：既然满足条件的 key 是连续的 19920101 ~ 19971231，那 join 条件就等价于一个范围过滤：\n1 lo.orderdate \u0026gt;= 19920101 AND lo.orderdate \u0026lt;= 19971231 直接对事实表的 orderdate 列做范围比较即可，不需要构建 hash table，也不需要 hash 计算。范围比较只是两次整数大小比较，比 hash lookup 快得多。\n如果主键不连续怎么办？ 比如过滤后剩下的 custkey 是 {2, 5, 7, 12, 15}，不是一段连续范围，没法直接做 between 改写。解决方法是通过 dictionary encoding 将这些值重新按序编码：\n原始 custkey 编码后 2 0 5 1 7 2 12 3 15 4 同时对事实表的外键列也做同样的编码转换。这样编码后的值就是连续的 0 ~ 4，又可以做 between-predicate 改写了。\n小结 实验结果 行存模拟 vs 真正列存 论文的实验结果中有两组关键对比：\n对比一：RS（MV）vs CS — 在行存中使用物化视图（最优方案）和真正的列存进行比较。即使物化视图已经做到了只读查询需要的列（reduce I/O），行存仍然和列存有不少差距。虽然这与系统本身实现有关，但仍能说明问题——因为 System X 作为商业系统实际上比 C-Store 更成熟。\n对比二：CS vs CS（row-mv） — 在列存系统 C-Store 中，把查询需要的几列提前拼成行的形式存储（比如查询只用 custkey、revenue、orderdate 三列，就把这三列按行拼在一起存成一个物化视图）。这样数据也只包含需要的列，I/O 量和原生列存一样少，但因为数据按行存放，列存的压缩、延迟物化、块式迭代等优化就都用不上了。实验结果是 CS（row-mv）性能远不如原生列存。这说明 reduce I/O 并不是列存系统的核心优势——列存的优势主要来自查询引擎层面的优化。\n在行存的几种模拟方案中，只有物化视图的性能相对较好，因为 MV 可以做到只读查询需要的列。其他模拟手段（垂直分区、仅索引扫描）反而会因为额外开销而降低性能。\n各优化技术的性能贡献 论文通过逐步添加优化，量化了每项优化的贡献（从右到左依次叠加）：\n优化组合 性能提升 延迟物化 3 倍 + 压缩 额外 2 倍 + Block Processing 额外 5% ~ 50% + Invisible Join 额外 50% ~ 75% 一个重要结论：没有任何单一优化能解释列存的全部优势——是这些优化的协同作用造就了列存的高性能。 而这些优化又深度依赖于列式的物理存储布局，因此无法简单地移植到行存系统中。\n核心启示 Column-Store 的优势不止在于它的存储格式，查询引擎层的各种优化同样关键。受限于 Row-Store 本身的存储格式，即使在查询引擎层添加了上述各种优化，效果也不会很好。\n列存的成功是从存储布局、压缩方案、查询执行、join 策略到结果物化的端到端协同设计。真正的系统创新往往需要打破抽象边界，让各层协同优化，而不是在某一层上做局部改进。\n后续影响 这篇 2008 年的论文对数据库领域产生了深远影响：\n列存成为分析数据库的标配：今天几乎所有主流分析数据库（ClickHouse、DuckDB、Apache Doris、StarRocks、Snowflake、BigQuery、Redshift）都采用列式存储。由于它们的\u0026quot;卓越性能\u0026quot;，已经成长为主导数据仓库/OLAP 市场。 列式文件格式的兴起：Apache Parquet 和 ORC 等列式文件格式成为大数据生态的事实标准，即使在 Hadoop/Spark 等非数据库系统中也广泛使用。 向量化执行引擎：论文中讨论的块式迭代思想发展为现代数据库中的向量化执行引擎（如 DuckDB 的向量化执行、Velox 引擎），成为 OLAP 性能优化的核心技术。 HTAP 系统的兴起：一些数据库开始同时支持行存和列存（如 TiDB 的 TiFlash、SQL Server 的列存索引），针对不同工作负载使用不同的存储格式。 从 2008 年到今天，列存已经从一个学术界的研究方向变成了工业界的标准实践。而这篇论文的贡献在于：它不仅证明了列存确实更好，更重要的是解释了为什么更好——这种理解帮助后续的系统设计者知道应该在哪些方向上继续优化。\n","permalink":"https://luoyuxia.github.io/posts/%E5%88%97%E5%AD%98-vs-%E8%A1%8C%E5%AD%98%E5%AE%83%E4%BB%AC%E5%88%B0%E5%BA%95%E6%9C%89%E5%A4%9A%E5%A4%A7%E5%B7%AE%E5%88%AB/","summary":"解读 SIGMOD 2008 经典论文 Column-Stores vs. Row-Stores: How Different Are They Really?，深入分析列存数据库相对于行存的性能优势究竟来自哪里。","title":"列存 vs 行存：它们到底有多大差别？"},{"content":"如果说 Agent 让人第一次意识到，大模型已经不只是\u0026quot;回答问题\u0026quot;，而开始能够\u0026quot;推进任务\u0026quot;，那么 Code Agent 则把这种感觉推到了一个非常具体、非常震撼的位置：\nAI 不只是会聊代码、会写代码，而是真的开始像一个能进入项目、理解上下文、动手修改、再自己验证结果的同事。\n这就是为什么很多人第一次真正被 agent 打到，不是在聊天场景，而是在代码场景。\n因为到了这里，AI 的强不再只是抽象的\u0026quot;好像挺聪明\u0026quot;，而是变成了一种极其具体的工作体验：\n它会找文件 它会读仓库 它会改代码 它会跑测试 它会根据报错继续修 它最后交付的不是一句建议，而是一份可验证的改动 所以，从 Agent 到 Code Agent，真正值得讲的，不是\u0026quot;AI 会不会写代码\u0026quot;，而是：\n为什么软件工程会成为 agent 最早爆发、也最像同事的一块场景。\n这条路线上有五篇关键论文，分别覆盖了从\u0026quot;会写代码\u0026quot;到\u0026quot;会在真实仓库里干活\u0026quot;再到\u0026quot;系统化平台\u0026quot;的完整演进。\n一、Codex：代码生成能力的起点 论文：Evaluating Large Language Models Trained on Code 作者：Mark Chen, Jerry Tworek, Heewoo Jun 等（OpenAI） 发表：2021 年\n这篇论文做了什么 在真正的 code agent 出现之前，先发生了一件更基础的事：语言模型先学会了写代码。\nOpenAI 在 GPT-3 的基础上，用从 GitHub 5400 万个公开仓库中收集的 159GB Python 代码做微调，训练出了 Codex 系列模型。论文同时提出了 HumanEval 基准——164 个手写编程题，每个题包含函数签名、文档字符串和若干单元测试，用于评估模型生成的代码是否能通过测试（即 functional correctness，功能正确性）。\n核心贡献：证明代码是大模型的天然能力领域 关键实验结果：\nGPT-3（175B）在 HumanEval 上的 pass@1 几乎为 0%——即便参数量巨大，没有经过代码数据微调的模型几乎无法生成正确程序。 Codex-12B 的 pass@1 达到 28.8%，pass@100（生成 100 个候选取最优）达到 72.31%。这意味着代码生成能力确实可以从大规模代码预训练中长出来。 论文还发现，代码能力随模型规模和训练数据量的增加呈现清晰的 scaling 趋势。 这篇论文回答了一个前置问题：代码是不是一种足够适合被语言模型学习的\u0026quot;语言\u0026quot;？ 答案是肯定的。Codex 后来直接驱动了 GitHub Copilot 的上线，让数百万开发者第一次在 IDE 里体验到了 AI 代码补全。\n但 code model 还不是 code agent Codex 终究还是 code model，而不是 code agent。\u0026ldquo;会写一段代码\u0026quot;和\u0026quot;会在一个真实仓库里完成任务\u0026quot;之间，差得非常远：\n对整个项目结构的理解 对问题描述和上下文约束的理解 对已有代码风格和模块关系的适应 对测试、构建、lint 结果的响应 对失败后的连续修正能力 code model 解决的是\u0026quot;代码生成\u0026quot;问题，而 code agent 要解决的是\u0026quot;软件工程执行\u0026quot;问题。\n二、为什么代码场景会成为 agent 最先爆发的地方 在进入后面几篇论文之前，先回答一个整篇文章最核心的问题：如果只从直觉上看，写代码并不是最简单的工作。那为什么偏偏是这里，AI 最早开始像\u0026quot;同事\u0026rdquo;？\n答案恰恰在于：\n软件工程虽然复杂，但它的环境特别结构化、反馈特别明确、结果特别容易验证。\n结构化环境。 代码库不是一团自然语言，它有文件树、模块边界、函数签名、类型约束、调用关系、配置和依赖。模型不是在一个完全模糊的任务空间里乱摸，而是在一个高度结构化的环境中工作。\n明确反馈。 代码场景里，很多结果都不是模糊的\u0026quot;差不多行\u0026quot;，而是非常硬的信号——测试过没过、编译成没成、lint 报没报错、类型检查通没通过、程序运行结果对不对。agent 每做一步，都能清楚知道自己是不是走偏了。\n可验证性。 很多知识工作里，\u0026ldquo;结果好不好\u0026quot;很难快速客观判定；而代码不同，它天然适合自动验收。\n这三点合起来——结构清晰、动作明确、反馈及时、验收客观——让软件工程成为 agent 的理想爆发场。也是为什么 code agent 给人的感觉特别像同事：它不是只会说一些\u0026quot;像样的话\u0026rdquo;，而是在一个有真实约束和真实反馈的环境里持续做事。\n三、SWE-bench：用真实 GitHub issue 重新定义评价标准 论文：SWE-bench: Can Language Models Resolve Real-World GitHub Issues? 作者：Carlos E. Jimenez, John Yang, Alexander Wettig, Shunyu Yao, Karthik Narasimhan 等（Princeton University） 发表：2023 年\n这篇论文做了什么 SWE-bench 把代码领域的评价标准彻底升级了。\n在传统代码生成时代，评估问的是：这个函数能不能补完、这道编程题能不能写对、这段代码像不像参考答案。但 SWE-bench 问的是另一件更接近真实工程的问题：\n模型能不能解决真实 GitHub 仓库里的 issue？\n论文从 12 个主流 Python 开源项目（包括 Django、Flask、scikit-learn、matplotlib、sympy、requests 等）中收集了 2,294 个真实任务实例。每个实例包含：\n一个真实的 GitHub issue 描述（bug 报告或 feature request） 对应时间点的完整仓库快照 开发者实际提交的修复 patch 用于验证修复是否正确的测试用例 核心贡献：把评价标准从\u0026quot;写得像\u0026quot;推进到\u0026quot;能不能修掉真实问题\u0026quot; SWE-bench 的意义不只是一个更难的 benchmark，而是它改变了整个评价视角：\n模型面对的不再是\u0026quot;写一段像样代码\u0026quot;，而是\u0026quot;理解一个真实的软件工程问题并完成修复\u0026quot;。\n真实 issue 意味着：问题来自真实开发环境、代码库不是人为简化的小样本、任务往往需要跨文件理解、修改是否正确要靠真实测试来判断。\n论文公布的初始结果非常有冲击力：\nClaude 2 在完整 SWE-bench 上的解决率约为 4.8% GPT-4 的解决率约为 1.7%（使用简单 prompting） 这些数字说明，即便是当时最强的模型，面对真实仓库级工程任务时，仅靠模型本身（没有 agent 框架）的成功率极低。这也直接催生了后续 SWE-agent 等 agent 系统的出现。\n论文还推出了 SWE-bench Lite——一个 300 个任务的精选子集，用于更快速地评估和迭代 agent 系统。这个子集后来成为了 code agent 领域最常用的对比基准。\n没有 SWE-bench 这一步，code agent 很容易只停留在\u0026quot;看起来很会写代码\u0026quot;的幻觉里。它把软件工程能力变成了可以明确比较、明确验证、明确追踪的目标。\n四、SWE-agent：给模型一个合适的计算机接口 论文：SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering 作者：John Yang, Carlos E. Jimenez, Alexander Wettig, Karthik Narasimhan, Shunyu Yao 等（Princeton University） 发表：2024 年\n这篇论文做了什么 如果说 SWE-bench 给出了目标，那么 SWE-agent 真正给出了\u0026quot;怎么打\u0026quot;的雏形。\n它最重要的洞察不是\u0026quot;换一个更大的模型\u0026quot;或\u0026quot;写一个更漂亮的 prompt\u0026quot;，而是：\n给模型一个合适的计算机接口（Agent-Computer Interface, ACI），让它真的能在仓库环境里工作。\n核心贡献：ACI——为 agent 设计的交互界面 论文提出了一个类似 UI/UX 设计的概念，但面向的不是人类用户，而是 LLM agent：Agent-Computer Interface（ACI）。\n核心思想是：人类使用计算机时有 GUI 和 CLI，这些界面是为人类认知习惯设计的。但 LLM agent 有不同的特点——它擅长处理文本、受限于上下文窗口、需要明确的操作反馈。所以应该专门为 agent 设计一套接口。\nSWE-agent 为此设计了一组定制命令：\nopen：打开指定文件并显示内容 scroll_up / scroll_down：在文件中翻页浏览 search_dir / search_file：在目录或文件中搜索关键词 edit：修改文件的指定行 create：创建新文件 同时集成了语法检查（linter）——每次编辑后自动检查语法错误，如果有问题会立即反馈给 agent，防止提交有语法错误的代码。\n关键结果 SWE-agent 搭配 GPT-4 在 SWE-bench 完整集上达到了 12.47% 的解决率——在当时是最高成绩。对比之下，没有 agent 框架的 GPT-4 直接 prompting 只有约 1.7%。\n同一个底模，从 1.7% 到 12.47%，差距来自 ACI 和 agent 循环的设计。 这直接说明了一件事：agent 的强不只是模型参数更大，还取决于它和计算机环境之间的接口设计。\n这个过程形成了一个清晰的工程闭环：\n先理解 issue 在仓库中搜索定位相关代码 做局部修改 运行测试验证 如果失败，根据报错调整 重复这个过程，直到结果通过 这就是 code agent 和普通聊天模型的真正分界线。普通聊天模型更像一个\u0026quot;懂很多代码知识的人\u0026quot;；而 code agent 开始像一个\u0026quot;能真正进项目里干活的人\u0026quot;。\n五、OpenHands：code agent 走向平台化 论文：OpenHands: An Open Platform for AI Software Developers as Generalist Agents 作者：Xingyao Wang, Boxuan Li, Yufan Song 等（UIUC 等） 发表：2024 年（前身为 OpenDevin）\n这篇论文做了什么 到 code agent 这一步，光看单个模型或单个 agent 实现已经不够了。因为 code agent 真正给人的震撼，很多时候不是来自某个底模突然变强了，而是来自一整套系统终于凑齐了。\nOpenHands（前身叫 OpenDevin）把 code agent 明确当成一个开源平台来构建，而不只是一个模型能力展示。\n核心贡献：标准化的 agent 系统架构 OpenHands 设计了一套完整的系统架构：\nEventStream 机制。 agent 和环境之间的所有交互（代码执行、文件操作、浏览器操作、用户消息）都被统一抽象成事件流。这使得不同类型的 agent 实现可以共享同一套环境接口。\nDocker 沙箱环境。 每个任务在独立的 Docker 容器中运行，提供隔离的执行环境。agent 可以安装依赖、运行命令、修改文件，而不会影响宿主系统。\n多 agent 架构支持。 平台不绑定某一种 agent 实现，而是支持多种架构——包括 CodeAct（用代码作为动作空间）、BrowsingAgent（浏览器操作）等——让研究者可以在同一个平台上对比不同的 agent 设计。\n完整的交付链。 从接收任务、理解上下文、搜索代码、修改文件、运行测试，到最终生成 patch 或 PR，整个流程在平台内闭环完成。\n为什么平台化很重要 一个让用户觉得\u0026quot;真像同事\u0026quot;的 code agent，背后往往同时依赖：\n底层语言模型的理解与生成能力 文件和仓库访问接口 命令执行环境 测试与验证机制 中间状态管理 多轮任务工作流 最终交付形式（patch、commit、PR） 到了这个阶段，竞争重点已经不只是\u0026quot;谁模型更聪明\u0026quot;，而越来越变成：谁系统更完整、谁闭环更扎实、谁工具接得更顺、谁交付结果更可靠。\nOpenHands 代表的是一种关键的认知升级：\ncode agent 的竞争，本质上是系统工程竞争。\n六、Agentless：把 code agent 从神话拉回工程现实 论文：Agentless: Demystifying LLM-based Software Engineering Agents 作者：Chunqiu Steven Xia, Yinlin Deng, Soren Dunn, Lingming Zhang（UIUC） 发表：2024 年\n这篇论文做了什么 讲到 code agent，特别容易一路往热血方向写。Agentless 这篇论文的重要性在于，它提供了一个很宝贵的\u0026quot;降温视角\u0026quot;。\n论文的核心质疑是：复杂的 agent 架构（多轮循环、自主决策、工具调用）是否总是比更简单的方法更有效？\n核心贡献：用两步流水线替代复杂 agent 循环 Agentless 把软件工程任务拆成两个阶段，完全不使用 agent 循环：\n第一阶段：层级化定位（Localization）。 给定一个 issue 描述，按层级逐步缩小范围：\n先让模型从整个仓库的文件结构中定位可能相关的文件 再在这些文件中定位可能相关的类或函数 最后精确到可能需要修改的具体代码行 每一步都是独立的 LLM 调用，不需要 agent 自主决定搜索策略。\n第二阶段：生成修复（Repair）。 基于定位结果，让模型生成多个候选 patch，然后用测试用例对这些 patch 进行排序和过滤，选出最可能正确的修复。\n最有冲击力的结论 Agentless 在 SWE-bench Lite 上达到了 27.3% 的解决率——在当时与复杂的 agent 系统（如 SWE-agent 约 18%）相比不仅不逊色，甚至更高。\n这个结果的冲击力很大，因为它意味着：\n没有 agent 循环 没有自主工具调用 没有多轮试错 只是结构化地拆解任务 + 多次 LLM 调用 + 测试排序 就已经达到了非常有竞争力的结果。而且这种方法更简单、更便宜、更快、更可控。\n它真正提醒了什么 Agentless 不是在否定 agent，而是在拆解 agent 的能力来源：\n很多看起来像 agent 魔法的提升，到底有多少来自复杂的 scaffold 设计，有多少来自底模本身的进步，有多少来自任务的结构化分解？\n它逼着我们认真区分：\n有些任务确实需要复杂 agent 的多轮循环 有些任务简单的流水线方法就够了 有些提升来自更好的检索和定位，而不是更复杂的多步规划 成本和复杂度并不总是值得的 所以更成熟的判断应该是：\ncode agent 确实已经非常强，但它的强是\u0026quot;工程上可解释的强\u0026quot;——来自可验证环境、结构化任务、闭环反馈和合理系统设计，而不是\u0026quot;魔法式的无边界强\u0026quot;。\n七、五篇论文的技术轨迹 论文 年份 解决的核心问题 关键成果 Codex 2021 代码能不能从预训练中学会 HumanEval pass@1: 28.8%，驱动 GitHub Copilot SWE-bench 2023 如何评估真实工程能力 2,294 个真实 GitHub issue，GPT-4 仅解决 1.7% SWE-agent 2024 给模型什么样的接口才能干活 ACI 设计，SWE-bench 解决率 12.47% OpenHands 2024 code agent 如何平台化 标准化架构，多 agent 支持 Agentless 2024 是否一定需要复杂 agent 两步流水线达到 27.3%（SWE-bench Lite） 这五篇论文画出了一条清晰的递进线：\n先证明代码能力可以从预训练中长出来（Codex） 再用真实工程任务定义目标（SWE-bench） 再通过接口设计让模型真正进入仓库工作（SWE-agent） 再把 agent 做成可复用的系统平台（OpenHands） 最后认真拆解哪些提升来自 agent，哪些来自其他因素（Agentless） 八、结论：Code Agent 让 AI 第一次具备了\u0026quot;交付能力\u0026quot; 如果把整个系列前面三篇连起来看，会发现有一条非常清楚的递进线：\n从 GPT-1 到 GPT-3，讲的是生成能力怎么长出来 从 GPT-3 到 ChatGPT，讲的是生成能力怎么被塑造成助手 从 ChatGPT 到 Agent，讲的是助手怎么开始会做事 到了 Agent → Code Agent，这条线终于推进到了一个最具体、最有生产力感的位置： AI 开始具备交付能力。\ncode agent 不只是给你建议、帮你想方案、告诉你可能怎么改，而是越来越能直接进入仓库工作、产出可检查的变更、跑验证、最终交付一个\u0026quot;可以被验收的结果\u0026quot;。\n当一个系统开始具备这种能力时，\u0026ldquo;像同事\u0026quot;这件事就不再只是比喻，而会越来越像一个现实判断。因为在很多工作里，所谓同事感，本质上就是三件事：\n理解上下文 按约束做事 交付可验证结果 Code Agent 之所以让人震撼，就是因为它第一次在一个高价值场景里，把这三件事同时做得有样子了。\n一句话总结 从 Agent 到 Code Agent，真正发生的事不是\u0026quot;AI 突然学会了写代码\u0026rdquo;，而是：\n代码场景天然具备结构化环境、明确反馈和可自动验收标准，于是 agent 在这里第一次真正进入了\u0026quot;执行—验证—修复—交付\u0026quot;的工程闭环，也因此开始像同事一样干活。\n","permalink":"https://luoyuxia.github.io/posts/%E4%BB%8E-agent-%E5%88%B0-code-agentai-%E4%B8%BA%E4%BB%80%E4%B9%88%E7%AA%81%E7%84%B6%E5%83%8F%E5%90%8C%E4%BA%8B%E4%B8%80%E6%A0%B7%E5%B9%B2%E6%B4%BB/","summary":"从 Agent 到 Code Agent，AI 开始具备交付能力。本文梳理了五篇关键论文——Codex、SWE-bench、SWE-agent、OpenHands、Agentless，拆解代码场景为何成为 agent 最先爆发的领域，以及 AI 如何从\u0026rsquo;会写代码\u0026rsquo;演进到\u0026rsquo;能在真实仓库里执行—验证—修复—交付\u0026rsquo;的工程闭环。","title":"从 Agent 到 Code Agent：AI 为什么突然像同事一样干活"},{"content":"如果说 ChatGPT 让普通人第一次认真和 AI 对话，那么 Agent 则让越来越多人第一次认真考虑：AI 是不是已经不只是一个\u0026quot;回答问题的东西\u0026quot;，而开始变成一个\u0026quot;可以执行任务的东西\u0026quot;。\n这两个阶段看起来很像，底层却差很多。\nChatGPT 最强的地方，是它能理解问题、组织语言、进行多轮交流，让人感觉自己面对的是一个\u0026quot;会说话的助手\u0026quot;。\n但 agent 的目标已经不只是\u0026quot;把答案说好\u0026quot;，而是：\n理解目标 决定下一步 调用工具 接收环境反馈 根据结果继续修正 最后把任务做完 也就是说，从 ChatGPT 到 Agent，真正发生的变化是：\n模型的评价标准，从\u0026quot;回答得像不像\u0026quot;变成了\u0026quot;任务到底有没有完成\u0026quot;。\n这条路线上有五篇关键论文，它们依次解决了一个越来越深的问题，合在一起构成了从\u0026quot;会回答\u0026quot;到\u0026quot;会做事\u0026quot;的完整技术轨迹。\n在正式进入 agent 之前，还值得提一下以 o1 为代表的推理模型。它们更适合放在 Part 3 前面，而不是塞进 Part 2 主线，因为它们解决的核心问题不是\u0026quot;模型怎么更像助手\u0026quot;，而是\u0026quot;模型怎么更会拆步骤、更会想、更能在长链条任务里保持推理稳定\u0026quot;。也正是这类能力的增强，让后来的 agent 不再只是会调用工具的聊天机器人，而更像一个会持续推进任务的执行系统。\n一、ChatGPT 为什么还不等于 Agent 关于 GPT-3 到 ChatGPT 的完整技术演进，可以参考《从 GPT-3 到 ChatGPT：AI 为什么突然像助手了》。\n理解 agent 的第一步，是先看清 ChatGPT 的边界。\nChatGPT 已经很强，它能解释概念、总结文本、生成代码、帮你写提纲、进行多轮追问。这让人非常容易产生一种错觉：它既然这么会说，是不是已经等于\u0026quot;会做事\u0026quot;了？\n其实不是。因为很多真实任务，根本不是靠\u0026quot;说\u0026quot;就能完成的：\n你问一个实时问题，模型需要去查 你让它完成复杂计算，模型需要工具验证 你让它操作文件、调用 API、整理数据，它需要真的执行动作 你让它在一个多步骤任务中不断修正，它需要读环境反馈而不是只靠记忆 纯聊天模型在这里会暴露一个明显的限制：\n它擅长语言交互，但不天然擅长与外部世界持续互动。\nChatGPT 的能力很大程度上仍然是\u0026quot;语言空间里的能力\u0026quot;；而 agent 想做的，是把这些能力推进到\u0026quot;任务空间\u0026quot;和\u0026quot;环境空间\u0026quot;。\n二、Chain-of-Thought Prompting：模型开始学会分步推理 论文：Chain-of-Thought Prompting Elicits Reasoning in Large Language Models 作者：Jason Wei, Xuezhi Wang, Dale Schuurmans 等（Google Brain） 发表：2022 年\n这篇论文做了什么 很多人一讲 agent，就直接跳到工具调用。其实在那之前，还有一个更基础的问题：模型到底会不会把复杂任务拆成步骤。\n在数学题、逻辑题、符号推理题上，直接让模型输出最终答案，表现往往不稳定。原因并不神秘——有些问题不是一步到位能答出来的，正确答案依赖中间推导，如果模型没有显式展开过程，就更容易在中间跳步或出错。\nCoT 的方法非常朴素：不是换模型结构，而是换提示方式。在 few-shot 示例中，不再只给\u0026quot;问题 → 答案\u0026quot;，而是给\u0026quot;问题 → 分步推理过程 → 最终答案\u0026quot;。让模型模仿这种\u0026quot;先推理，后回答\u0026quot;的输出模式。\n核心贡献：把推理从隐性能力变成显式接口 论文在 GSM8K（小学数学）、SVAMP、AQuA 等多个推理基准上做了实验。关键发现：\n在 PaLM 540B 上，CoT 将 GSM8K 的准确率从标准 prompting 的约 18% 提升到约 57%——同一个模型，仅靠改变 prompt 格式就获得了三倍以上的提升。 CoT 是一种涌现能力（emergent ability）：在小模型（如 10B 以下）上几乎没有效果甚至有害，但一旦模型规模足够大（约 100B+），效果就会显著涌现。 论文还探索了 zero-shot CoT：仅在 prompt 末尾加上\u0026quot;Let\u0026rsquo;s think step by step\u0026quot;这句话，不需要任何示例，大模型就能自发展开推理过程。 为什么这对 agent 至关重要 这一步为 agent 打下了一个非常重要的前提。因为 agent 的核心不是\u0026quot;知道答案\u0026quot;，而是\u0026quot;知道当前该做哪一步\u0026quot;。\n不会分步想，就很难分步做。\nCoT 让行业更明确地看到了三件事：\n模型不是只能直接输出一个答案，它可以在 prompt 引导下把过程显式展开。 \u0026ldquo;把过程写出来\u0026quot;这件事本身，会显著提升复杂任务表现。 推理能力不是纯粹黑箱的——它可以被提示方式强烈影响，是一种可调用、可观察的接口。 CoT 的局限也很清楚：模型写出了推理步骤，但还没有真正去调用工具、访问环境或执行动作。它更像\u0026quot;闭门思考\u0026rdquo;——可能想得很漂亮，但如果中间某个事实记错了，后面会一路错下去。这个问题，要等下一篇论文来解决。\n三、ReAct：推理和行动合成一个循环 论文：ReAct: Synergizing Reasoning and Acting in Language Models 作者：Shunyu Yao, Jeffrey Zhao, Dian Yu 等（Princeton University \u0026amp; Google Brain） 发表：2023 年\n这篇论文做了什么 如果 CoT 解决的是\u0026quot;能不能分步推理\u0026quot;，那 ReAct 真正解决的就是：\n能不能把推理和行动合成一个循环。\n这几乎是 agent 范式最重要的变化之一。\nReAct 的核心直觉来自一个对称的观察：\n光想不够：CoT 的推理完全在模型内部进行，可能建立在错误记忆上一路想下去，无法自我纠正。 光做也不够：没有推理引导的工具调用会很盲目——模型可能乱搜索、乱点击，却不知道自己为什么要做这些事。 所以更好的方式是：边想边做，边做边根据结果继续想。\n核心贡献：Thought-Action-Observation 循环 论文定义了一种交替生成结构，每一步包含三个部分：\nThought（想法）：模型写出当前的推理，比如\u0026quot;我需要先查 X 的出生年份\u0026quot; Action（动作）：模型发起一个具体操作，比如 Search[X birthdate] Observation（观察）：环境返回动作结果，比如搜索引擎返回的页面内容 模型根据 Observation 更新思路，生成下一个 Thought，决定下一个 Action——如此循环，直到任务完成。\n论文在四类任务上做了实验：\nHotpotQA（多跳问答）：需要跨多个文档推理才能回答的问题。ReAct 通过交替搜索和推理，显著降低了幻觉率。 FEVER（事实验证）：判断一个声明是否能被 Wikipedia 支持或反驳。 ALFWorld（文本交互游戏）：在虚拟家庭环境中执行复杂指令，如\u0026quot;把一个热的苹果放在桌上\u0026quot;。ReAct 的成功率比纯行动方法高出显著幅度。 WebShop（网页购物模拟）：根据用户需求在模拟电商网站上搜索并购买合适商品。 实验发现，ReAct 在需要与外部信息交互的任务上显著优于纯 CoT，因为推理可以指引行动方向，而行动结果又反过来纠正推理中的错误假设。\n为什么这是 agent 的分水岭 这就是后来几乎所有 agent 都在模仿的基本结构——agent loop 的雏形：\n理解当前任务 形成一个局部判断 执行一个动作 观察动作结果 根据新结果更新下一步思路 它的重要性在于，它第一次把大模型从\u0026quot;回答系统\u0026quot;明显推向\u0026quot;执行系统\u0026quot;。在这之后，模型就不再只是一个把输入变成输出的黑箱，而更像一个会在环境中不断调整行为的决策者。同时，因为每一步的 Thought 和 Action 都被显式展开，agent 的行为变得可观察、可解释、可调试。\n四、Toolformer：模型学会自己决定何时调用工具 论文：Toolformer: Language Models Can Teach Themselves to Use Tools 作者：Timo Schick, Jane Dwivedi-Yu, Roberto Dessì 等（Meta AI） 发表：2023 年\n这篇论文做了什么 即便有了 ReAct 的推理-行动循环，如果模型还是不知道什么时候该调用工具、该调用哪个工具，它仍然不够强。\nToolformer 要解决的问题非常直接：语言模型能不能自己学会在合适的时候调用外部工具？\n这篇论文的方法非常有代表性。它不是简单给模型硬编码一堆工具调用模板，也不是靠人工标注大量\u0026quot;这里该调工具\u0026quot;的数据，而是设计了一套自监督标注流程，让模型自己学会在什么地方插入工具调用。\n核心贡献：自监督的工具使用学习 论文基于 GPT-J（6.7B 参数）做实验，具体流程分四步：\n第一步：示范工具调用格式。 先给模型少量示例，展示工具调用的 API 格式，比如 [Calculator(3*7+5)] 或 [Search(\u0026quot;population of France\u0026quot;)]。\n第二步：让模型自己标注。 在大量普通文本上，让模型自己决定在哪些位置插入工具调用——也就是说，模型自己判断\u0026quot;这个地方如果能调用计算器/搜索引擎/翻译器，可能会有帮助\u0026quot;。\n第三步：执行并过滤。 真的去执行这些工具调用，拿到结果。然后用一个关键的过滤标准：如果插入工具调用的结果降低了后续文本的困惑度（perplexity），就保留这条标注；否则就丢弃。 这意味着模型只保留那些\u0026quot;真的有帮助\u0026quot;的工具调用。\n第四步：微调。 用过滤后的数据对模型做微调，让它在推理时自然地在合适位置生成工具调用。\n论文测试了五种工具：计算器、问答系统、搜索引擎、翻译系统和日历。结果发现，经过训练的 Toolformer 在不降低语言建模能力的前提下，在需要工具的下游任务上显著优于原始模型——一个 6.7B 的 Toolformer 在某些工具相关任务上甚至超过了更大的 GPT-3（175B）。\n为什么它是真正的分水岭 Toolformer 的意义不只是\u0026quot;模型能调用工具\u0026quot;，而是它改变了工具调用的性质：\n工具使用从外部工程拼接，变成了模型行为的一部分。\n在此之前，工具调用更多是系统层面的事——由外部代码判断什么时候该查什么。Toolformer 则让模型自己内化了这种判断。这为后来 OpenAI 的 Function Calling、各类 agent 框架的工具调用机制提供了重要的直觉基础。\n因为纯聊天模型的边界是：只能依赖训练时学到的知识，遇到实时问题容易过时，遇到精确计算容易出错，遇到外部状态变化无法感知。而工具调用一旦接上，模型突然就有了全新的能力维度——能查实时信息、能做精确计算、能检索文档、能访问外部系统状态。\n工具不是一个附加功能，而是把模型从\u0026quot;封闭文本系统\u0026quot;推进成\u0026quot;开放任务系统\u0026quot;的关键。\n五、Reflexion 与 Self-Refine：agent 学会从失败中修正自己 光有推理和工具也还不够。因为很多任务不是\u0026quot;调用一次工具就结束\u0026quot;——模型可能第一次推理就错了，可能第一次动作就失败了，可能拿到结果后发现前面的假设不成立。\n这就是 Reflexion 和 Self-Refine 这两篇论文的核心议题：agent 的强，不只来自\u0026quot;第一下有多聪明\u0026quot;，更来自\u0026quot;失败后能不能修正自己\u0026quot;。\nReflexion：从失败中提取语言化经验 论文：Reflexion: Language Agents with Verbal Reinforcement Learning 作者：Noah Shinn, Federico Cassano, Ashwin Gopinath 等（Northeastern University \u0026amp; MIT） 发表：2023 年\nReflexion 的核心创新是一种不更新模型参数的\u0026quot;强化学习\u0026quot;——它把反思本身变成上下文的一部分：\nAgent 执行一次完整的任务尝试 如果失败，获取环境反馈（比如测试不通过、答案错误） 模型根据反馈生成一段自然语言反思，总结\u0026quot;这次错在哪里、下次应该注意什么\u0026quot; 把这段反思存入一个记忆缓冲区（memory buffer） 下一轮尝试时，把之前的反思作为额外上下文注入 prompt，让模型避免重复犯错 这很像人在做复杂任务时的反思模式：上次错在这一步 → 这个工具不能这样用 → 下次先检查这个约束。\n论文在三类任务上验证了效果：\nHumanEval（代码生成）：Reflexion 将 pass@1 从基线的约 80% 提升到 91%，多轮反思让模型能根据测试失败信息修正代码。 AlfWorld（序列决策）：在虚拟环境中执行多步骤任务，Reflexion 通过积累任务经验显著提升了成功率。 HotpotQA（多跳问答）：通过反思之前的错误搜索策略，改善了信息检索质量。 Self-Refine：围绕当前输出持续打磨 论文：Self-Refine: Iterative Refinement with Self-Feedback 作者：Aman Madaan, Niket Tandon, Prakhar Gupta 等（CMU） 发表：2023 年\n如果说 Reflexion 更偏\u0026quot;从失败中总结经验，避免跨轮次犯同样的错\u0026quot;，那 Self-Refine 更偏另一种模式：围绕当前输出反复打磨。\nSelf-Refine 的流程非常清楚，用同一个大模型完成三个角色：\nGenerator（生成者）：先生成一个初稿 Critic（评价者）：对初稿给出具体反馈，指出哪里不够好 Refiner（修订者）：根据反馈修改初稿，产出新版本 这个\u0026quot;生成 → 反馈 → 修订\u0026quot;的循环可以重复多轮，直到输出质量趋于稳定。全程不需要额外的监督数据或外部反馈，模型自己评价自己并改进。\n论文在 7 类不同任务上做了测试，包括情感转换、对话回复、代码优化、数学推理、首字母缩写生成等。结果显示，经过迭代修订，输出质量在多数任务上提升了 5%–20%，且在部分任务上经过 2-3 轮迭代即可收敛到较好结果。\n两篇论文的关系和共同意义 两者的角度不同，但它们共同推动了 agent 的一个关键能力——迭代：\nReflexion Self-Refine 核心机制 跨轮次反思，记忆化经验 单轮次内反复打磨 反馈来源 外部环境（测试结果、任务成败） 模型自身（自评自改） 类比 \u0026ldquo;从失败中吸取教训\u0026rdquo; \u0026ldquo;对草稿反复修改\u0026rdquo; 是否更新参数 否（存入记忆缓冲区） 否（纯推理时迭代） \u0026ldquo;能否迭代\u0026quot;恰恰决定了模型到底像不像一个真正做事的人。因为真实工作里，很多高质量结果从来都不是一次生成的，而是：先试一次 → 看结果 → 调整 → 再继续试。agent 越来越强，不只是因为它更会回答，而是因为它越来越像一个会持续修正、持续推进、持续靠近目标的系统。\n六、五篇论文的技术轨迹 到这里，可以看清楚从 ChatGPT 到 Agent 的完整演进逻辑：\n论文 年份 解决的核心问题 关键能力 Chain-of-Thought 2022 模型能不能分步推理 显式多步推理 ReAct 2023 推理和行动能不能合成循环 Thought-Action-Observation 循环 Toolformer 2023 模型能不能自主决定调用工具 自监督工具使用 Reflexion 2023 失败后能不能从经验中修正 语言化反思记忆 Self-Refine 2023 输出能不能通过自我反馈持续改进 生成-评价-修订循环 从 ChatGPT 到 Agent，真正变化的往往不是\u0026quot;一个模型 able to do more\u0026rdquo;，而是模型被放进了一个完全不同的工作流里。\n在 ChatGPT 工作流里，典型模式是：用户提问 → 模型回答 → 用户追问 → 模型再回答。\n而在 agent 工作流里，模式变成了：用户给目标 → 模型拆任务 → 模型决定是否需要工具 → 模型调用动作 → 环境返回结果 → 模型根据结果继续推理 → 多轮执行后交付结果。\n评价体系也跟着变了。聊天模型更像在比语言是否自然、回答有没有帮助、像不像一个好助手。而 agent 更像在比任务拆解对不对、工具选择对不对、中间回退能力强不强、最后任务有没有完成。\n实际上，并不是模型一下子跨物种了，而是：\n推理被显式化了（CoT） 推理和行动被统一了（ReAct） 工具被接进来了（Toolformer） 失败修正机制开始形成了（Reflexion + Self-Refine） 这四件事叠加起来，模型就开始从\u0026quot;回答者\u0026quot;变成\u0026quot;执行者\u0026quot;。\n七、结论：从\u0026quot;语言智能\u0026quot;到\u0026quot;行动智能\u0026quot;的过渡 如果 GPT-3 到 ChatGPT 讲的是\u0026quot;模型怎么从会生成变成像助手\u0026quot;，那 ChatGPT 到 Agent 讲的就是：\n模型怎么从\u0026quot;像助手\u0026quot;变成\u0026quot;会做事\u0026quot;。\n这个阶段最核心的变化，不是模型更会说，而是它终于开始具备这些能力的组合：\n会拆复杂任务（CoT） 会在中间步骤里将推理和行动交替进行（ReAct） 会借助外部工具扩展自身边界（Toolformer） 会根据失败经验修正下一轮尝试（Reflexion） 会对当前输出反复打磨逼近更好结果（Self-Refine） 这几件事合在一起，才构成了今天大家说的 agent 感。\n所以 agent 不是一个简单的\u0026quot;加了插件的聊天机器人\u0026quot;，也不是一个\u0026quot;更长答案的 ChatGPT\u0026quot;。它真正代表的是一种新的系统形态：\n语言模型不再只负责表达，而开始负责决策、协调和执行。\n一句话总结 从 ChatGPT 到 Agent，真正发生的事不是\u0026quot;模型突然有了手脚\u0026quot;，而是：\n研究者先把大模型的多步推理能力显式调出来，再把推理和行动组织成循环，再让模型学会调用工具、利用反馈、持续修正，最终让它从会回答的问题机器，变成了会推进任务的执行系统。\n","permalink":"https://luoyuxia.github.io/posts/%E4%BB%8E-chatgpt-%E5%88%B0-agent%E6%A8%A1%E5%9E%8B%E4%B8%BA%E4%BB%80%E4%B9%88%E5%BC%80%E5%A7%8B%E4%BC%9A%E5%81%9A%E4%BA%8B/","summary":"从 ChatGPT 到 Agent，模型的评价标准从\u0026rsquo;回答得像不像\u0026rsquo;变成了\u0026rsquo;任务到底有没有完成\u0026rsquo;。本文梳理了这段演进中的五篇关键论文——Chain-of-Thought、ReAct、Toolformer、Reflexion、Self-Refine，拆解模型如何从会回答的问题机器，变成了会推进任务的执行系统。","title":"从 ChatGPT 到 Agent：模型为什么开始会做事"},{"content":"如果说 GPT-3 让整个行业第一次意识到，大模型已经不只是一个研究玩具，而是一个具备通用能力的系统，那么 ChatGPT 则让更多普通人第一次真切感受到：AI 不只是会写一段像样的文字，它开始像一个能配合你完成任务的助手。\n很多人会把这段变化理解成\u0026quot;GPT-3 再升级了一点\u0026quot;。但如果真想把这段历史讲清楚，就会发现这不是一句\u0026quot;模型更大了\u0026quot;能解释的。\n从 GPT-3 到 ChatGPT，真正发生的变化是：\n模型的核心问题，从\u0026quot;能力够不够强\u0026quot;变成了\u0026quot;能力能不能以人类喜欢的方式被调用、被约束、被组织出来\u0026quot;。\n也就是说，GPT-3 和 ChatGPT 之间的差距，不主要是\u0026quot;有没有能力\u0026quot;，而是\u0026quot;这些能力有没有被塑造成一个可用助手\u0026quot;。\n这条路线上有四篇关键论文，它们各自解决了一个不同层面的问题，合在一起几乎就能拼出 GPT-3 到 ChatGPT 的完整技术轨迹。\n一、GPT-3：通用能力的起点 论文：Language Models are Few-Shot Learners 作者：Tom Brown, Benjamin Mann, Nick Ryder 等（OpenAI） 发表：2020 年\n关于 GPT-1 到 GPT-3 的完整技术演进，可以参考《从 GPT-1 到 GPT-3：现代大语言模型的技术底座是如何形成的》。\n这篇论文做了什么 GPT-3 训练了一个 1750 亿参数的自回归语言模型，使用了约 300B token 的混合语料（包括 Common Crawl 过滤后数据、WebText2、Books 语料和 Wikipedia）。论文同时训练了从 1.25 亿到 1750 亿共 8 个不同规模的模型，用于观察能力如何随规模变化。\n核心贡献：in-context learning GPT-3 真正震撼行业的地方，不只是参数量，而是它把一种全新的任务适配方式做成了明确的现象——in-context learning。\n在 GPT-3 之前，让模型\u0026quot;学会一个任务\u0026quot;的标准做法仍然是：收集任务数据 → 微调模型 → 评估表现。而 GPT-3 改变了这件事：\n任务说明可以写在 prompt 里（zero-shot） 示例可以直接放在上下文里（one-shot / few-shot） 模型在这一轮推理中就能临时适配任务模式，无需更新任何参数 论文在翻译、问答、完形填空、算术推理等数十个任务上测试了这种能力，发现 few-shot 场景下 GPT-3 已经可以接近甚至超越一些经过专门微调的小模型。更关键的是，这种能力随模型规模增长而显著提升——越大的模型，越能从上下文示例中学到东西。\n它已经证明了什么 GPT-3 证明了三件事：\n语言模型可以在不更新参数的情况下完成大量不同任务。 少量示例本身就是一种任务接口，prompt 可以替代微调。 同一个模型可以在翻译、问答、摘要、补全、简单推理等大量任务间复用。 如果只看能力上限，GPT-3 已经是一个非常强的通用文本系统。\n但它还不是助手 问题也正出在这里。GPT-3 是强大的\u0026quot;通用文本系统\u0026quot;，却还不是一个成熟的\u0026quot;通用助手系统\u0026quot;。\n因为 GPT-3 的训练目标始终是\u0026quot;预测下一个 token\u0026quot;，它的默认行为更接近续写，而不是服务。这会带来几个真实的问题：\n用户在提问，但模型不一定真正把它当成\u0026quot;任务请求\u0026quot; 用户想要一个直接回答，模型却可能展开成一段像网页正文的文字 用户需要诚实和边界感，模型却可能一本正经地胡说 用户期待稳定风格，模型却可能时而像助手、时而像论坛帖子、时而像说明文档 这说明一个关键事实：\n通用能力出现了，不等于产品可用性也自动出现。\nGPT-3 解决的是\u0026quot;模型能不能做很多事\u0026quot;，但还没有解决\u0026quot;模型能不能按用户真正想要的方式做这些事\u0026quot;。\n二、Learning to Summarize from Human Feedback：把人类偏好变成可训练的目标 论文：Learning to Summarize from Human Feedback 作者：Nisan Stiennon, Long Ouyang, Jeff Wu 等（OpenAI） 发表：2020 年\n为什么这篇论文容易被忽略但非常关键 很多人讲 GPT-3 到 ChatGPT，会直接跳到 InstructGPT。但中间有一个非常重要的技术预演，就是这篇论文。\n它要解决的问题非常具体：当任务目标本身很难形式化时，能不能直接用人类偏好来训练模型？\n传统监督学习的前提是：你有唯一的\u0026quot;标准答案\u0026quot;，模型去拟合这个答案。但很多生成任务没有唯一标准答案。比如一段摘要到底算不算\u0026quot;好\u0026quot;，要看是否忠实原文、是否简洁、是否抓住重点、是否自然可读——这些标准很难直接写成一个简单的损失函数。\n核心贡献：RLHF 路线的原型 这篇论文在摘要任务上跑通了后来被称为 RLHF（Reinforcement Learning from Human Feedback）的完整路线，几乎成了后续所有人类反馈训练的标准模板：\n第一步：先有一个初始模型。 基于预训练模型做监督微调，让它具备基础的摘要生成能力。\n第二步：让人类比较输出优劣。 不是让人类从零写标准答案，而是让标注者比较同一输入的两个候选摘要，判断哪个更好。这一步非常关键——\u0026ldquo;比较\u0026quot;通常比\u0026quot;从零写最优答案\u0026quot;更容易、更稳定、成本更低。\n第三步：训练奖励模型（Reward Model）。 根据人类偏好比较数据，训练一个 RM，去预测\u0026quot;哪种输出更符合人类偏好\u0026rdquo;。\n第四步：用强化学习优化生成模型。 以 RM 的评分作为奖励信号，用 PPO 算法优化原模型，使其更倾向产出高奖励的输出。\n论文发现，经过 RLHF 训练的模型生成的摘要，在人类评估中显著优于单纯用监督学习训练的模型，甚至在某些场景下接近人类撰写的摘要质量。\n它为什么对 ChatGPT 这么重要 因为 ChatGPT 的核心不是单纯\u0026quot;更会生成\u0026quot;，而是\u0026quot;更符合人类偏好\u0026quot;。而\u0026quot;符合人类偏好\u0026quot;恰恰很难靠普通监督学习直接搞定。\n这篇论文提前证明了三件事：\n人类偏好是可以被系统性收集的。 偏好可以训练成一个可量化的奖励模型。 奖励模型可以反过来通过强化学习优化生成模型。 它把模糊的\u0026quot;人类喜欢什么\u0026quot;，变成了可训练的机器目标。 虽然它聚焦的只是摘要这一个任务，但它验证的方法论，直接成了后来 InstructGPT 和 ChatGPT 的训练基础。\n三、InstructGPT：把续写器训练成助手 论文：Training Language Models to Follow Instructions with Human Feedback 作者：Long Ouyang, Jeff Wu, Xu Jiang 等（OpenAI） 发表：2022 年\n这篇论文要解决什么 如果 GPT-3 的问题是\u0026quot;像续写器，不像助手\u0026quot;，那 InstructGPT 要解决的就是：\n怎么把一个强大的续写器，重新训练成一个更听用户话的助手。\n这听起来像产品包装，实际上是很硬的训练问题。GPT-3 在预训练阶段学到的是海量互联网文本分布，它会学到很多知识、模式和表达方式，但它不会天然知道：什么叫\u0026quot;有帮助的回答\u0026quot;、什么叫\u0026quot;不要跑题\u0026quot;、什么叫\u0026quot;当不知道时要承认不知道\u0026quot;、什么叫\u0026quot;简洁但完整地完成任务\u0026quot;。\n核心贡献：三阶段后训练流程 InstructGPT 把\u0026quot;Learning to Summarize\u0026quot;验证过的 RLHF 方法，从单一摘要任务推广到了通用指令遵循场景，形成了后来几乎成为行业标准的三阶段后训练模板。\n第一阶段：监督微调（SFT）。 OpenAI 雇佣了约 40 名标注者，收集了大量\u0026quot;用户指令—理想回答\u0026quot;示例。输入不再是普通文本，而是一个请求、一个任务、一个问题；输出不再是自然延续，而应该是一个直接、清楚、有帮助的回答。用这些数据对 GPT-3 做监督微调，让模型先学会基本的\u0026quot;助手格式\u0026quot;——先让模型知道，它现在是在帮一个人完成任务。\n第二阶段：训练奖励模型（RM）。 针对同一个用户请求，让模型生成 4-9 个候选回答，再让标注者对这些回答进行排序。基于排序数据训练一个 6B 参数的奖励模型，学习预测\u0026quot;人类更喜欢哪个回答\u0026quot;。这一步把人类偏好显式编码进了一个可优化的目标函数。\n第三阶段：PPO 强化学习优化。 用奖励模型给主模型的输出打分，再通过 PPO 算法优化主模型，使其更倾向产出高奖励的回答。同时加入 KL 散度约束，防止模型为了迎合奖励模型而偏离预训练分布太远。\n最有冲击力的结论 这篇论文一个非常出名的发现是：\n经过 RLHF 训练的 1.3B InstructGPT，在人类评估中有时会比原始 175B 的 GPT-3 更受偏好。\n这意味着：一个小 100 多倍的模型，仅仅因为经过了后训练对齐，就能在用户体验上超过未经对齐的巨型模型。\n这件事的冲击很大，因为它说明：后训练和对齐不只是锦上添花，而是能根本性地改变实际体验。 模型的\u0026quot;好用程度\u0026quot;不完全由参数量决定，训练目标的对齐同样关键。\n它改变了什么 从\u0026quot;会生成\u0026quot;到\u0026quot;会遵循指令\u0026quot;：GPT-3 需要比较会写 prompt 的人才能调用好；InstructGPT 开始让普通用户用自然表达也能得到合理的回答。 从\u0026quot;统计上合理\u0026quot;到\u0026quot;偏好上合意\u0026quot;：传统语言建模优化的是\u0026quot;什么文本最可能出现\u0026quot;；InstructGPT 优化的是\u0026quot;什么回答更符合用户期待\u0026quot;。 优化目标变了：以前是 $P(\\text{next token} \\mid \\text{context})$，现在还要加上 $R_{\\text{human}}(\\text{response})$。 严格来说，ChatGPT 并没有一篇公开的完整技术主论文。所以在学术叙事里，InstructGPT 往往就是最接近\u0026quot;ChatGPT 技术来源\u0026quot;的那篇论文。\n四、WebGPT：模型不只要会说，还要会查 论文：WebGPT: Browser-assisted Question-Answering with Human Feedback 作者：Reiichiro Nakano, Jacob Hilton, Saurav Kadavath 等（OpenAI） 发表：2022 年\n这篇论文做了什么 WebGPT 把语言模型和一个受限浏览器环境结合起来。模型不再只是坐在原地硬答，而是可以执行一系列浏览器操作：\n发起搜索查询（Search） 点击搜索结果链接（Click） 在页面中上下滚动阅读（Scroll） 引用页面中的相关段落（Quote） 基于检索到的证据组织最终回答（Answer） 论文在 ELI5（Explain Like I\u0026rsquo;m 5）这个长文本问答数据集上做了实验。他们先用人类演示数据做行为克隆（模仿人类的搜索-浏览-回答流程），再用 RLHF 进一步优化模型的检索和回答质量。\n核心贡献：把问答从纯生成推进到\u0026quot;搜索-证据-回答\u0026quot; WebGPT 最重要的贡献，是把\u0026quot;回答问题\u0026quot;从纯语言生成，推进成了一种有明确证据链的工作流程：先查资料、找到证据、再基于证据组织回答。\n这说明 OpenAI 在那个阶段已经不满足于\u0026quot;让模型说得更像助手\u0026quot;，而是在探索一件更深的事：\n一个真正可靠的助手，不应该只会说，还应该会查。\n为什么这件事重要？因为只靠参数记忆做问答，会遇到几个根本性的问题：\n知识可能过时（训练数据有截止日期） 细节可能记错（模型会\u0026quot;幻觉\u0026quot;） 模型可能把似是而非的话说得很像真的 用户很难验证来源和依据 论文发现，经过训练的 WebGPT 生成的带引用回答，在人类评估中有时能达到甚至超过人类在 ELI5 上撰写的回答质量。更重要的是，因为每个回答都附带了来源引用，可验证性大幅提升。\n它对后续发展的影响 WebGPT 虽然不是 ChatGPT 本身，但它提前暴露出一条后来越来越重要的路线：\n模型能力不只来自参数本体，也来自它能不能连接到外部工具和信息世界。\n这条线在后来的发展中不断延伸：ChatGPT Plugins、Browsing 模式、函数调用（Function Calling），以及更广泛的 Agent 和工具调用（Tool Use）范式，都可以追溯到 WebGPT 提前验证的思路。\n所以 GPT-3 到 ChatGPT 的过渡，不只是一个\u0026quot;对齐故事\u0026quot;，也是一个\u0026quot;模型如何更像真实助手工作方式\u0026quot;的故事。\n五、为什么说 ChatGPT 的爆发，本质上是\u0026quot;能力被重新组织\u0026quot;了 到这里，你会发现，ChatGPT 的出现并不是凭空增加了一种全新智能，而是把前面几步已经出现的东西重新组织起来了：\n论文 年份 解决的问题 GPT-3 2020 通用能力是否存在 Learning to Summarize 2020 人类偏好能否变成训练目标 InstructGPT 2022 通用能力能否被对齐成助手 WebGPT 2022 助手能否连接外部工具和证据 到了 ChatGPT，这些东西第一次被合成成了一个大众一用就懂的产品形态。\n聊天框并不是这场革命里最深的技术部分，却是最重要的产品接口。因为它让用户不需要理解什么是 few-shot、什么是奖励模型、什么是强化学习、什么是监督微调。\n用户只会感受到一件事：\n这个系统不像以前的语言模型那样只会生成文本，而是真的在配合我。\n这就是 ChatGPT 成为分水岭的原因。不是因为它第一次拥有全部能力，而是因为它第一次把\u0026quot;通用能力 + 指令跟随 + 人类偏好 + 对话交互\u0026quot;组合成了一个稳定、直接、人人能用的体验。\n六、结论：ChatGPT 不是\u0026quot;更强一点的 GPT-3\u0026quot;，而是\u0026quot;被重新对齐过的 GPT-3\u0026quot; 如果一定要把这段历史压缩成一句话：\nGPT-3 让模型拥有了通用能力，ChatGPT 则让这种通用能力第一次以\u0026quot;助手\u0026quot;的形态稳定呈现出来。\nGPT-3 到 ChatGPT 之间最本质的变化，不是参数表里某一列数字多了多少，而是训练目标变了。\n以前的目标是：\n让模型尽可能学会互联网文本分布 后来的目标变成：\n让模型理解用户请求（SFT） 按人类偏好组织回答（RM + PPO） 在需要时借助外部工具获取证据（Tool Use） 尽量成为一个可协作、可接受、可直接使用的助手 也正因为如此，ChatGPT 才不只是一个模型版本，而像是一次产品形态和训练范式的共同拐点。\n一句话总结 从 GPT-3 到 ChatGPT，真正发生的事不是\u0026quot;模型突然会聊天了\u0026quot;，而是：\n研究者先用 GPT-3 把通用能力做出来，再通过指令微调、人类反馈和工具增强，把这种能力重新塑造成了一个更听话、更有帮助、更像助手的系统。\n","permalink":"https://luoyuxia.github.io/posts/%E4%BB%8E-gpt-3-%E5%88%B0-chatgptai-%E4%B8%BA%E4%BB%80%E4%B9%88%E7%AA%81%E7%84%B6%E5%83%8F%E5%8A%A9%E6%89%8B%E4%BA%86/","summary":"从 GPT-3 到 ChatGPT，真正发生的变化不是\u0026rsquo;模型更大了\u0026rsquo;，而是训练目标变了。本文梳理了这段技术演进中的四篇关键论文——GPT-3、Learning to Summarize from Human Feedback、InstructGPT、WebGPT，拆解通用能力如何通过指令微调、人类反馈强化学习和工具增强，被重新塑造成一个可用的助手系统。","title":"从 GPT-3 到 ChatGPT：AI 为什么突然像助手了"},{"content":"\n当 AI Agent 需要新鲜上下文：CocoIndex，一个声明式增量数据索引框架 从一个真实问题说起 假设你在一家中型技术公司工作，团队在搭一个面向客户的 Support Agent。公司的产品文档、FAQ、故障排查指南分散在 Confluence、Google Docs 和一个 Git 仓库里，总共几千篇，每周都在更新。客户的问题五花八门：用的是业务术语而不是技术关键词（\u0026ldquo;为什么我的订单消失了\u0026quot;而不是 soft_delete），涉及跨文档的上下文（计费规则在一篇文档里，退款流程在另一篇），有时候是模糊的概念性提问（\u0026ldquo;你们支持多租户吗\u0026rdquo;）。这种场景下 grep 和关键词搜索基本没用——用户的用词和文档里的术语对不上。\n技术方案是 RAG：把所有文档分块（chunk），生成向量嵌入（embedding），存入向量数据库。客户提问时，用语义相似度检索最相关的块，拼入 LLM 的上下文窗口生成回答。\n第一版很快就跑起来了。但问题马上来了。\n产品文档不是静态的。新功能上线要加文档，旧功能下线要归档，故障排查指南随着事故复盘不断补充。每天有几十次更新。产品经理刚更新了退款政策，但 Agent 回答客户的还是上周的版本——因为索引是昨晚定时任务全量重建的。客户按照过时的步骤操作，发现不对，投诉升级。更糟的是，全量重建要处理几千篇文档，调用嵌入 API 花费不少钱和时间，你不敢跑太频繁。\n好，你决定做增量更新。于是你开始在 Python 脚本里加逻辑：记录每个文件的 mtime，跟上一次比较，只处理变化的文件。听起来简单？接着你发现一篇已下线功能的文档被归档删除了，但它对应的 chunks 还留在向量数据库里，Agent 继续引用已经不存在的功能来回答客户——这些\u0026quot;幽灵数据\u0026quot;比没有数据更危险。你得追踪每个文件产生了哪些 chunks，删除文件时把对应的 chunks 也删掉。然后你发现，改了 chunk 大小之后，所有文件都应该重新分块——但 mtime 没变，你的增量逻辑跳过了它们。\n于是你开始加版本号、加 hash、加状态数据库。一周之后你发现，你写的增量同步逻辑比你的业务逻辑还复杂，而且还有 bug。\n这个问题不是你的能力问题。它是一个系统性的缺失：当数据在持续变化，而你需要让下游的索引始终保持新鲜，谁来帮你管理这个\u0026quot;源 → 目标\u0026quot;的同步？\n传统的 ETL 工具（Airflow、Dagster）是面向批处理的——定时全量跑，每次重头来过。CDC 工具（Debezium）面向数据库行级变更，但它不理解你的应用逻辑——它不知道一篇文档变了之后，应该重新分块、重新嵌入、删除旧 chunks、插入新 chunks。自己写增量逻辑？那就是上面描述的痛苦。\nCocoIndex 就是为了解决这个问题而生的。\n一个最小的例子：感受差异 在展开 CocoIndex 的设计之前，先看一个最小任务：把一个目录下的 Markdown 文件分块、生成嵌入、存入 PostgreSQL（pgvector），并在文件变化时保持同步。\n手写 Python 脚本的做法是：扫描目录，对每个文件读取内容，调用分块函数，对每个 chunk 调用嵌入模型，写入数据库。要做增量，你得自己维护一张状态表，记录每个文件的 hash 和它产生的 chunk IDs。文件修改时，查出旧 chunks 删除，插入新 chunks。文件删除时，查出并删除它的所有 chunks。代码改了 chunk 策略时……你的状态表不知道这件事，只能手动清库重跑。\n用 Airflow/Dagster 好一些：你可以把分块、嵌入、写入定义成 DAG 的 task，加上调度和重试。但增量的核心问题没变——你仍然需要自己在 task 里写\u0026quot;哪些文件变了\u0026quot;的判断逻辑，仍然需要自己管理\u0026quot;文件删除时清理下游数据\u0026quot;的关联。DAG 管的是任务编排，不是数据同步。\nCocoIndex 的写法是声明式的：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @coco.fn(memo=True) async def process_file(file, table): text = await file.read_text() for chunk in RecursiveSplitter().split(text, chunk_size=2000): table.declare_row( text=chunk.text, embedding=await embedder.embed(chunk.text), ) @coco.fn async def main(sourcedir): table = await postgres.mount_table_target(PG, \u0026#34;docs\u0026#34;) table.declare_vector_index(column=\u0026#34;embedding\u0026#34;) await coco.mount_each(process_file, localfs.walk_dir(sourcedir).items(), table) coco.App(coco.AppConfig(name=\u0026#34;docs\u0026#34;), main, sourcedir=\u0026#34;./docs\u0026#34;).update_blocking() 注意这段代码里没有任何增量逻辑。没有 mtime 比较，没有状态表，没有 \u0026ldquo;if changed then reprocess\u0026rdquo;。你只是声明了\u0026quot;每个文件应该产生哪些 chunks\u0026rdquo;，CocoIndex 引擎自动处理剩下的一切：\n文件没变？memo=True 让函数直接跳过，不重新执行 文件修改了？只重新处理这个文件，旧 chunks 自动删除，新 chunks 自动插入 文件被删了？它的组件（component）不再存在，引擎自动清理它拥有的所有目标状态 代码改了（比如 chunk_size 从 2000 改成 1000）？函数的代码 hash 变了，memo 缓存失效，所有文件自动重新处理 三者的本质区别不在代码量，而在于谁负责维护\u0026quot;源到目标\u0026quot;的一致性。手写脚本和 DAG 工具把这个责任推给了开发者；CocoIndex 把它收进了引擎。你只需要声明\u0026quot;目标应该长什么样\u0026quot;，引擎负责让现实和声明保持一致。这就像 React 之于前端——你声明 UI 应该是什么状态，框架负责最小化地更新 DOM。\nWhat：CocoIndex 是什么 一句话：CocoIndex 是一个声明式增量数据索引框架，让你用\u0026quot;声明目标状态\u0026quot;的方式构建数据管道，引擎自动处理增量同步、缓存失效和数据清理。\n它的核心主张印在项目首页上：Your agents deserve fresh context. 你的 AI Agent 值得拥有新鲜的上下文。\n技术上，CocoIndex 是一个 Python API + Rust 引擎的混合体。用户面对的是 Python 的 async/await 接口，底层是一个由多个 Rust crate 构成的增量计算引擎，通过 PyO3 暴露 Python 绑定。Rust 层处理组件树管理、memo 指纹计算、状态持久化（LMDB）、目标状态协调（reconciliation）这些计算密集的核心逻辑；Python 层负责用户函数、连接器（connector）、嵌入模型调用这些需要灵活性的部分。\n这个架构选择和我们之前分析过的 Daft 类似——计算密集用 Rust，灵活性留给 Python——但解决的问题完全不同。Daft 是一个查询引擎，优化的是\u0026quot;一次计算如何尽可能快\u0026quot;；CocoIndex 是一个索引框架，优化的是\u0026quot;连续多次计算之间如何尽可能少做\u0026quot;。\n三个关键词定义了 CocoIndex 的独特性：\n声明式：你不写\u0026quot;如果文件变了就重新处理\u0026quot;的命令式逻辑，你只声明\u0026quot;每个文件应该产生什么目标数据\u0026quot;，引擎自动做 diff 和 reconciliation 增量：通过 memo 指纹（hash(inputs) + hash(code)）和组件所有权追踪，只重新处理变化的数据，跳过不变的部分 全链路：从源数据变更检测、函数级缓存、到目标状态的 create/update/delete，整条链路由引擎统一管理 Why：传统方案哪里不够 批处理太慢、太贵 最朴素的做法是定时全量重建索引：每隔几小时扫描所有文件，重新分块，重新嵌入，重新写入。\n这在数据量小的时候可以接受，但随着规模增长，成本曲线是陡峭的。假设你有 10000 个文档，每次全量重建需要调用嵌入模型处理所有 chunks——即使其中 99.9% 没有变化。如果你用的是付费嵌入 API（OpenAI、Cohere），每次全量重建的费用和延迟都是线性增长的。更关键的是延迟：全量重建可能需要几十分钟到几小时，这意味着你的 Agent 在这段时间里使用的是过时的上下文。\n对于 AI Agent 来说，上下文的新鲜度直接影响决策质量。一个正在帮用户排查 bug 的 coding agent，如果它查到的代码索引还是两小时前的版本，它可能会基于已经被修复的代码给出错误的建议。\n自建增量逻辑很脆弱 很多团队的第一反应是\u0026quot;加一层缓存\u0026quot;：记录每个文件的 hash，变了才重新处理。但真正的增量同步远比这复杂：\n删除问题：文件被删除了，你怎么知道它在向量数据库里留下了哪些 chunks？你需要一张映射表，记录 file → [chunk_ids]。但如果你的分块逻辑变了（比如 chunk_size 从 2000 改成 1000），同一个文件产生的 chunk 数量变了，旧的映射关系就失效了。\n代码变更问题：你改了嵌入模型从 MiniLM 换成 BGE，所有 chunks 都应该重新嵌入。但你的增量逻辑只看文件的 mtime 或 content hash——文件没变，它不会触发重新处理。你需要把\u0026quot;代码版本\u0026quot;也纳入缓存 key，但手动管理这个 key 极其容易遗漏。\n级联问题：一个文件变了，它的 chunks 变了，但如果你还有一层\u0026quot;跨文件去重\u0026quot;或\u0026quot;跨文件关系图\u0026quot;，变化会沿着依赖链传播。手写这种级联失效逻辑几乎不可能正确。\n这些问题不是 corner case——它们是任何认真做增量的系统都会遇到的核心难题。CocoIndex 把这些复杂度收进了框架。\n现有工具解决的是不同的问题 Airflow / Dagster 是工作流编排工具。它们管的是\u0026quot;任务 A 完成后触发任务 B\u0026quot;，不管\u0026quot;任务 A 的输入数据有哪些行变了\u0026quot;。你可以在 Airflow 的 task 里写增量逻辑，但框架本身不会帮你。\nDebezium / CDC 工具 解决的是\u0026quot;数据库行级变更捕获\u0026quot;。它们能告诉你数据库表里哪些行变了，但它们不理解你的应用逻辑。\u0026ldquo;一行文档变了\u0026rdquo;→\u0026ldquo;需要重新分块、重新嵌入、删除旧 chunks、插入新 chunks\u0026quot;这条链路，CDC 不管。\nLlamaIndex / LangChain 提供了 RAG 的构建块（splitter、embedder、retriever），但它们本质上是库（library），不是框架（framework）。它们帮你做了一次性的 indexing，但不帮你管理\u0026quot;当源数据变化时，如何最小化更新索引\u0026rdquo;。\nCocoIndex 填的是这个空白：一个在应用逻辑层面理解\u0026quot;源 → 变换 → 目标\u0026quot;关系的增量同步引擎。\nHow：CocoIndex 怎么做到的 核心模型：TargetState = F(SourceState) CocoIndex 的整个设计围绕一个等式：\n1 TargetState = Transform(SourceState) 你的代码 Transform 是一个纯函数（或者说，一个可被 memo 的函数）——给定相同的输入，产生相同的输出。CocoIndex 引擎的职责是：当 SourceState 变化时，以最小的代价让 TargetState 重新等于 Transform(SourceState)。\n这个模型的优雅之处在于，它把所有的增量复杂度从用户代码中移除了。用户只需要写那个 Transform 函数——声明目标应该长什么样——引擎负责 diff、cache、reconciliation。\n组件树与所有权追踪 这是 CocoIndex 最核心的设计，也是它能\u0026quot;自动清理幽灵数据\u0026quot;的根本原因。为了讲清楚，先从用户代码出发，看看引擎在背后做了什么。\n回到前面那个最小例子：\n1 2 3 4 5 6 7 8 9 10 @coco.fn(memo=True) async def process_file(file, table): text = await file.read_text() for chunk in RecursiveSplitter().split(text, chunk_size=2000): table.declare_row(text=chunk.text, embedding=await embedder.embed(chunk.text)) @coco.fn async def main(sourcedir): table = await postgres.mount_table_target(PG, \u0026#34;docs\u0026#34;) await coco.mount_each(process_file, localfs.walk_dir(sourcedir).items(), table) 假设 sourcedir 下有三个文件：readme.md、api.md、guide.md。当你调用 app.update_blocking() 时，引擎内部发生了什么？\n第一步：构建组件树 每一个 @coco.fn 函数的调用都会创建一个组件（Component）。mount_each 对文件列表的每个元素调用 process_file，所以会创建三个子组件。每个组件有一个稳定路径（StablePath），由函数名 + 元素的 key 拼成：\n1 2 3 4 /main ← main() 创建的根组件 ├── /main/process_file/readme.md ← mount_each 为 readme.md 创建的子组件 ├── /main/process_file/api.md ← mount_each 为 api.md 创建的子组件 └── /main/process_file/guide.md ← mount_each 为 guide.md 创建的子组件 路径里的 key（readme.md、api.md）来自文件名，不是数组下标。这很重要：即使文件列表的顺序变了（比如新加了一个文件排在前面），每个文件对应的组件路径不变。这和 React 列表渲染时给每个元素加 key 是同一个思路。\n第二步：执行函数，记录\u0026quot;谁产生了什么\u0026quot; 每个子组件执行 process_file 函数。假设 readme.md 分成了 2 个 chunk，api.md 分成了 3 个 chunk，guide.md 分成了 1 个 chunk。每次调用 table.declare_row(...) 时，引擎在内部记录：这一行是哪个组件声明的。\n执行完成后，引擎在 LMDB 中存储了一张所有权表，大概长这样：\n1 2 3 4 5 组件路径 拥有的目标状态（数据库行） ────────────────────────────────────────────────────────── /main/process_file/readme.md → [chunk_1, chunk_2] /main/process_file/api.md → [chunk_3, chunk_4, chunk_5] /main/process_file/guide.md → [chunk_6] 这张表就是所有权的核心——每一行数据都能追溯到产生它的那个组件。\n第三步：文件被删了，会发生什么？ 现在用户删除了 readme.md。下一次 app.update_blocking() 时：\nmain() 重新执行，mount_each 遍历当前文件列表，只有 api.md 和 guide.md 了，不再为 readme.md 创建子组件。\n引擎比较\u0026quot;这次有哪些子组件\u0026quot;和\u0026quot;上次有哪些子组件\u0026quot;：\n1 2 3 上次：[readme.md, api.md, guide.md] 这次：[api.md, guide.md] 差异：readme.md 消失了 引擎查所有权表，找到 /main/process_file/readme.md 拥有 [chunk_1, chunk_2]。\n引擎向 PostgreSQL 发送 DELETE FROM docs WHERE id IN (chunk_1, chunk_2)。\n清理所有权表中 /main/process_file/readme.md 的条目。\n整个过程中用户没有写任何删除逻辑。 引擎的推理链条是：文件不存在了 → 组件不再被创建 → 组件拥有的数据应该被删除。这就是\u0026quot;所有权追踪\u0026quot;的全部含义。\n第四步：文件被修改了呢？ 如果 api.md 被修改了（比如加了一段内容），流程是这样的：\nmount_each 仍然为 api.md 创建子组件，路径还是 /main/process_file/api.md。\nmemo=True 检查发现文件内容的 hash 变了，缓存失效，process_file 重新执行。\n重新分块后，api.md 现在产生了 4 个 chunk（之前是 3 个）。函数声明了 [chunk_3', chunk_4', chunk_5', chunk_7]。\n引擎比较本次声明和上次存储的目标状态，做 diff：\n1 2 3 4 上次：[chunk_3, chunk_4, chunk_5] 这次：[chunk_3\u0026#39;, chunk_4\u0026#39;, chunk_5\u0026#39;, chunk_7] 操作：UPDATE chunk_3→chunk_3\u0026#39;, UPDATE chunk_4→chunk_4\u0026#39;, UPDATE chunk_5→chunk_5\u0026#39;, INSERT chunk_7 所有权表更新为新的 chunk 列表。\n而 guide.md 没变，memo 指纹匹配，process_file 根本不执行，它的 chunks 也不会被碰。\n为什么要用墓碑（Tombstone）？ 上面第三步\u0026quot;删除 readme.md 的 chunks\u0026quot;描述的是理想情况。但如果删除到一半进程崩溃了怎么办？比如 chunk_1 已经删了，chunk_2 还没删。\nCocoIndex 的做法是：发现组件消失后，不立即执行删除，而是先写一条墓碑记录到 LMDB。墓碑的意思是\u0026quot;这个组件需要被清理\u0026quot;。然后再根据墓碑执行实际的删除操作。如果删除中途崩溃，墓碑还在，下次重启时引擎会重新扫描墓碑，继续未完成的清理。\n墓碑是整个机制的崩溃恢复安全网——确保不会因为进程崩溃而留下\u0026quot;删了一半\u0026quot;的脏数据。\nMemo 指纹：不只是输入 hash CocoIndex 的缓存机制比简单的 \u0026ldquo;input hash → output\u0026rdquo; 更精细。当你在函数上标注 @coco.fn(memo=True) 时，引擎计算的 memo 指纹包含两部分：\n输入指纹：函数参数的 hash 代码指纹：函数本身的 hash（通过 logic_tracking 机制） 这意味着：\n文件内容没变 → 输入指纹不变 → 跳过执行（即使你重启了进程） 文件内容变了 → 输入指纹变了 → 重新执行 你改了 chunk_size 参数 → 代码指纹变了 → 所有文件重新执行 你换了嵌入模型 → 嵌入模型注册为 ContextKey 且 detect_change=True → 相关指纹变化 → 重新嵌入 第三点尤其重要。传统的缓存只看输入数据，不看处理逻辑。你换了模型但数据没变，缓存命中，索引没更新——这是一个隐蔽的 bug。CocoIndex 通过把代码纳入指纹来避免这类问题。\n指纹的持久化使用 LMDB——一个嵌入式键值数据库。LMDB 的特点是读性能极高（无锁，mmap），非常适合\u0026quot;频繁检查缓存是否命中\u0026quot;的场景。指纹以组件路径为 key 存储，即使进程重启，缓存依然有效。\n目标状态协调（Reconciliation） 当一个组件执行完成后，它声明的目标状态需要和\u0026quot;上一次运行的目标状态\u0026quot;做对比，产生具体的数据库操作。这个过程叫 reconciliation：\n1 2 3 4 5 本次声明的行 上次存储的行 操作 ───────────────────────────────────────────────── row(id=1, v=\u0026#34;new\u0026#34;) row(id=1, v=\u0026#34;old\u0026#34;) → UPDATE row(id=2, v=\u0026#34;hello\u0026#34;) （不存在） → INSERT （不存在） row(id=3, v=\u0026#34;stale\u0026#34;) → DELETE 这个 diff 发生在引擎内部，不需要用户参与。用户的代码只有 declare_row(...)——声明\u0026quot;这一行应该存在且内容是这样的\u0026quot;。至于它是新增、修改还是不变，引擎自动判断。\nReconciliation 还支持**附件（Attachments）**的概念。比如 table.declare_vector_index(column=\u0026quot;embedding\u0026quot;) 声明了一个向量索引——这个索引是表的附件，当表的数据变化时，附件也会相应更新。\n崩溃恢复：重启等于一次增量更新 一个自然的问题是：CocoIndex 进程挂了或者重启了怎么办？\n答案是：重启之后的行为和正常的增量更新完全一致，不需要任何特殊的恢复流程。 这是因为所有关键状态都持久化在 LMDB 中：\nMemo 指纹在 LMDB 里 → 重启后引擎遍历所有组件，逐一检查指纹。文件没变的，指纹匹配，直接跳过；文件变了的，指纹不匹配，重新处理。和正常增量更新的逻辑完全一样。 所有权索引在 LMDB 里 → 引擎知道哪些目标状态属于哪个组件，不会因为重启而丢失这层关系。 墓碑在 LMDB 里 → 如果上一次运行中途崩溃，一个组件的删除操作只执行了一半（比如墓碑写入了但目标状态还没清理），重启后 GC 扫描会发现这个墓碑，继续执行未完成的清理。 StablePath 是内容派生的 → 同一个文件无论在哪次运行中，都会产生相同的组件路径，memo 缓存自然命中。 换句话说，CocoIndex 没有\u0026quot;冷启动\u0026quot;和\u0026quot;热运行\u0026quot;的区别。每次 update() 调用都是同一个流程：遍历组件树 → 检查指纹 → 跳过没变的 → 处理变了的 → reconcile 目标状态 → 清理墓碑。第一次运行是\u0026quot;全部都是新的\u0026quot;，重启后是\u0026quot;大部分都命中缓存\u0026quot;，逻辑路径完全相同。这种设计的好处是没有额外的恢复代码路径——恢复就是正常运行，不存在\u0026quot;恢复模式的 bug\u0026quot;这种东西。\n上下文与资源管理 AI 管道中有些资源是全局共享的——数据库连接池、嵌入模型实例、配置参数。CocoIndex 用 ContextKey[T] 和 @coco.lifespan 来管理这些资源：\n1 2 3 4 5 6 7 8 9 PG_DB = coco.ContextKey[asyncpg.Pool](\u0026#34;db\u0026#34;) EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder](\u0026#34;embedder\u0026#34;, detect_change=True) @coco.lifespan async def setup(builder: coco.EnvironmentBuilder): async with asyncpg.create_pool(DATABASE_URL) as pool: builder.provide(PG_DB, pool) builder.provide(EMBEDDER, SentenceTransformerEmbedder(\u0026#34;all-MiniLM-L6-v2\u0026#34;)) yield detect_change=True 是一个精妙的设计：它告诉引擎\u0026quot;这个资源的值会影响计算结果\u0026quot;。如果你把嵌入模型从 MiniLM 换成 BGE，EMBEDDER 的变化会被检测到，所有依赖它的组件的 memo 指纹会失效，触发重新计算。而数据库连接池 PG_DB 设为 detect_change=False（默认值），因为换一个数据库连接不应该导致重新嵌入。\nLive 模式：从批处理到持续同步 CocoIndex 不止于一次性的批处理。当源数据支持变更通知时（比如文件系统的 inotify、Kafka 的消费组），CocoIndex 可以进入 Live 模式——持续监听变化，增量更新。\n1 files = localfs.walk_dir(sourcedir, live=True) # 声明源支持 live watch 1 cocoindex update -L main # -L 启用 live 模式 在 Live 模式下，引擎不会在处理完所有文件后退出，而是保持运行，监听文件系统事件。当一个文件被修改时，只有对应的组件被重新执行，其他组件的缓存不受影响。这让你的索引可以在亚秒级别保持新鲜。\nRust 引擎：为什么不是纯 Python CocoIndex 的增量逻辑——指纹计算、组件树遍历、状态存储、reconciliation——全部在 Rust 层实现。这不是\u0026quot;用 Rust 重写就更快\u0026quot;的教条，而是有具体的技术原因：\n指纹计算是热路径。每次运行时，引擎需要对所有组件计算 memo 指纹来决定是否跳过。这涉及大量的 hash 计算（blake2）和 LMDB 查询。在 10000 个文件的场景下，纯 Python 实现的开销可能比实际的数据处理还大，抵消了增量的收益。\n组件树管理需要精确的内存控制。组件树用弱引用（weak reference）追踪子组件的存活状态，用 RAII 管理组件的生命周期——组件被 drop 时自动触发清理。这些模式在 Rust 中是零成本的，在 Python 中则依赖 GC 和 __del__，不可靠且有性能开销。\n项目的 benchmark 数据印证了这一点：在 10000+ 文件的语料库上，Rust 引擎比纯 Python 实现快 30-80 倍。CPU 密集型工作负载（指纹计算、状态对比）的加速比高达 80 倍，I/O 密集型工作负载（文件读取、数据库写入）也有 17-23 倍的提升。\n连接器生态：不止向量数据库 CocoIndex 的目标状态不限于向量数据库。它提供了 17+ 种连接器，覆盖了 AI 应用常见的后端：\n类别 连接器 关系数据库 PostgreSQL、SQLite、Doris 向量数据库 pgvector、Qdrant、LanceDB、Turbopuffer 图数据库 Neo4j、FalkorDB、SurrealDB 消息队列 Kafka、Apache Iggy 对象存储 Amazon S3、Google Drive、OCI Object Storage 本地文件 localfs（支持 watch） 这意味着你可以用同一个声明式模型构建不同类型的索引：向量搜索索引、知识图谱、全文搜索表、甚至 Kafka 消息流——增量同步的逻辑对所有后端都是一样的。\n什么时候用 CocoIndex，什么时候不用 适合的场景：\n你在构建 RAG 应用，需要让知识库索引随源数据保持同步 你的源数据持续变化（代码仓库、文档库、Slack、邮件），不想每次全量重建 你的数据管道涉及昂贵的操作（嵌入生成、LLM 调用），希望只在必要时执行 你需要同时维护多种目标（向量数据库 + 知识图谱 + 搜索索引），希望它们保持一致 你需要 Live 模式，让索引在亚秒级别保持新鲜 不适合的场景：\n一次性的数据分析或探索（你只需要跑一次，不需要增量）——直接用 Pandas / Polars 大规模的多模态数据批处理（百万张图片的分类、转码）——Daft 或 Ray Data 更合适，它们的执行引擎针对吞吐量优化 纯实时流处理（毫秒级延迟的事件处理）——Flink / Kafka Streams 更合适 简单的 CRUD 同步（数据库表到表的复制）——CDC 工具（Debezium）更直接 一个有趣的对比：Daft 和 CocoIndex 解决的是数据管道的两个不同维度的问题。Daft 优化的是单次执行的效率——如何让一次全量处理尽可能快（流水线并行、资源隔离、filter 下推）。CocoIndex 优化的是多次执行之间的效率——如何让连续的增量更新尽可能少做（memo 缓存、组件所有权、reconciliation）。它们不冲突，甚至可以互补——你可以在 CocoIndex 的处理函数里使用 Daft 来高效处理每一批数据。\n为什么不用 Flink 这是一个值得认真回答的问题。Flink 也在做\u0026quot;数据变了 → 更新下游\u0026quot;的事情，而且它是一个久经生产验证的系统。CocoIndex 做的事情，Flink 能不能做？\n技术上可以，但你会一直在和框架的设计意图对着干。\nFlink 的核心抽象是事件流上的有状态计算——无限的事件序列，每条事件触发一次算子执行，状态在 checkpoint 中持久化。CocoIndex 的核心抽象是源到目标的声明式同步——有限但持续变化的数据集，声明目标应该长什么样，引擎负责 diff 和 reconciliation。\n这两个抽象层次的差异，在以下几个具体场景中会变得非常明显：\n文件修改 Flink 的 FileSource 是面向\u0026quot;新文件出现\u0026quot;设计的（append-only 语义）。一个文件被修改了，FileSource 默认不会重新读取它。你需要自己写 Custom Source，定期扫描文件的 mtime 或 content hash，发现变化后重新发出整个文件内容。这不是不行，但你已经在 Flink 里重新造一个文件变更检测系统了。\nCocoIndex 的 localfs.walk_dir(live=True) 原生支持文件修改检测，配合 memo=True，只有内容真正变化的文件才会触发重新处理。\n文件删除 → 清理下游 这是最拧巴的地方。假设文件 A 之前产生了 5 个 chunks 写入了向量数据库，现在文件 A 被删除了。\n在 Flink 里，你需要：\n自己检测文件被删了——FileSource 不管删除事件，你得自己写扫描逻辑 在 Keyed State 里查出文件 A 对应哪 5 个 chunk ID——你得自己维护这个映射 对每个 chunk 发一条 retraction / DELETE 事件到下游 下游 sink 收到 DELETE 事件后执行数据库删除——你得在 sink 里写处理删除的逻辑 每一步都要你自己写。而在 CocoIndex 里，文件对应的组件不再被 mount，它\u0026quot;拥有\u0026quot;的目标状态自动被清理。零行代码。\n代码逻辑变更 你把 chunk_size 从 2000 改成 1000。在 Flink 里：\n停掉 job 判断是否能从 savepoint 恢复（大概率不行，因为算子逻辑变了，state schema 可能不兼容） 清空下游数据库 从头重跑整个 job Flink 的 state 是为运行时连续性设计的（checkpoint、savepoint），不是为逻辑变更后的缓存失效设计的。CocoIndex 的 code hash 让这变成一件自动的事——逻辑变了，指纹变了，缓存失效，只重新处理需要的部分。\n操作成本 为了\u0026quot;监听 5000 个文件，增量更新向量数据库\u0026quot;这个需求，Flink 需要一个 JVM 集群（JobManager + TaskManager，至少也得一个 standalone 进程）。CocoIndex 是 pip install cocoindex + 一个 Python 进程。\n总结 场景 Flink CocoIndex 新文件出现 → 处理 原生支持 原生支持 文件修改 → 重新处理 自己写 Custom Source 自动（memo 失效） 文件删除 → 清理下游 自己写 retraction 链路 自动（所有权清理） 代码逻辑变了 → 重新计算 停 job、清数据、重跑 自动（code hash 失效） 部署复杂度 JVM 集群 pip install + 单进程 Flink 的强项在别处：高吞吐的事件流处理、毫秒级延迟的实时计算、复杂的窗口聚合和流式 JOIN。这些 CocoIndex 做不了。但在\u0026quot;持续变化的数据集 → 保持下游索引新鲜\u0026quot;这个特定问题上，Flink 的抽象层次太低了——你要手动实现的增量逻辑，比你的业务逻辑还多。\n回到开头 几千篇产品文档，每天几十次更新，Support Agent 的索引需要保持新鲜——这个问题的本质是\u0026quot;源到目标\u0026quot;的持续同步。\nCocoIndex 做的事情，是把这个同步逻辑从你的代码中抽走：组件所有权让你不用写清理逻辑，memo 指纹让你不用写缓存逻辑，代码 hash 让你不用担心逻辑变更后缓存过期，reconciliation 让你不用写 INSERT/UPDATE/DELETE 判断，Live 模式让你不用写文件监听和增量触发。\n你只需要声明\u0026quot;每个文件应该产生什么数据\u0026quot;，其余的交给引擎。\n这就是 CocoIndex 的核心主张：索引管道不应该比业务逻辑更难写。 或者用它自己的话说——Your agents deserve fresh context.\n前端开发者花了十年时间，从手动操作 DOM 走到了 React 的声明式模型。数据管道正在经历类似的演进：从手写增量逻辑，到声明目标状态。CocoIndex 是这条路上的一个值得关注的尝试。\nTakeaway 增量的核心难题不是\u0026quot;检测变化\u0026quot;，而是\u0026quot;清理过时数据\u0026quot;。 检测哪些文件变了（比对 mtime / hash）相对简单。真正难的是：文件删了，它下游产生的 chunks 怎么找到并删掉？代码逻辑改了，哪些缓存该失效？这些关联关系的维护才是手写增量逻辑最容易出 bug 的地方。CocoIndex 用组件所有权追踪解决了前者，用 code hash 纳入 memo 指纹解决了后者。\n\u0026ldquo;声明目标状态\u0026quot;和\u0026quot;编写同步逻辑\u0026quot;是两种本质不同的编程模型。 前者只说\u0026quot;结果应该是什么样\u0026rdquo;，后者要说\u0026quot;如果 A 变了就做 X，如果 B 删了就做 Y，如果代码改了就做 Z\u0026quot;。后者的分支数量随场景复杂度指数增长。CocoIndex 的价值在于让你只写前者，把后者收进框架。这和 React（声明 UI 状态 vs 手动操作 DOM）、SQL（声明查询意图 vs 手写遍历逻辑）是同一个演进方向。\n","permalink":"https://luoyuxia.github.io/posts/%E5%BD%93-ai-agent-%E9%9C%80%E8%A6%81%E6%96%B0%E9%B2%9C%E4%B8%8A%E4%B8%8B%E6%96%87cocoindex%E4%B8%80%E4%B8%AA%E5%A3%B0%E6%98%8E%E5%BC%8F%E5%A2%9E%E9%87%8F%E6%95%B0%E6%8D%AE%E7%B4%A2%E5%BC%95%E6%A1%86%E6%9E%B6/","summary":"CocoIndex 是一个为 AI Agent 和 LLM 应用设计的声明式增量数据索引框架。Rust 引擎 + Python API，声明目标状态而非编写同步逻辑，只处理变化的增量数据。本文从 What / Why / How 三个维度分析 CocoIndex 的设计哲学和关键技术。","title":"当 AI Agent 需要新鲜上下文：CocoIndex，一个声明式增量数据索引框架"},{"content":"当 AI Agent 有了记忆：AgentMemory，一个为编程智能体设计的持久记忆引擎 从一个真实问题说起 你用 Claude Code 花了三个小时重构了一个复杂的认证模块。你告诉它项目的架构约定，哪些目录放什么代码，错误处理的偏好，测试的写法。它表现得很好，完全理解了你的意图。\n第二天你开了一个新会话，它把这一切全忘了。\n你需要重新解释项目结构，重新说明编码偏好，重新描述昨天的上下文。如果你不说，它可能做出和昨天完全矛盾的决策——比如用一种不同的错误处理模式，或者修改一个你昨天刚确定不要动的文件。\n这不是个别现象。每一个使用 AI 编程助手的开发者都在经历这件事。每个会话都是一张白纸。Agent 没有记忆。\n目前的解决方案大多是手动的。CLAUDE.md 文件本质上是一个静态的 prompt 注入——你手写一份说明文档，每次会话都塞进上下文窗口。它能解决一部分问题，但随着项目迭代，这个文件要么越来越长（token 开销线性增长），要么过时失效（没人记得更新）。240 条观察记录之后，一个典型的 CLAUDE.md 就超过 22,000 token 了，大部分是噪声。\n更深层的问题是：这不应该是人类的工作。Agent 自己产生了大量的上下文——它读了哪些文件，做了什么决策，遇到了什么错误，最终采用了哪种方案——这些信息本来就在工具调用的输入输出里。如果有一个系统能自动捕获、压缩、索引这些信息，并在下一次会话中精准地召回相关记忆，开发者就不需要当 Agent 的\u0026quot;外部记忆\u0026quot;了。\nAgentMemory 就是为了解决这个问题而生的。\n图：没有持久记忆的 Agent，每次新会话都是一张白纸——昨天的理解、决策和偏好全部归零。\n一个最小的例子：感受差异 在展开设计之前，先来感受一下有记忆和没记忆的区别。\n没有 AgentMemory 的时候，每次会话都是独立的：\n1 2 3 4 5 6 7 [Session 1] 你: \u0026#34;用 jose 库做 JWT 验证，不要用 jsonwebtoken\u0026#34; Agent: (完成了实现) [Session 2] 你: \u0026#34;给 API 加个鉴权中间件\u0026#34; Agent: import jsonwebtoken from \u0026#39;jsonwebtoken\u0026#39; // 用错了库 你: \u0026#34;不对，我们用的是 jose\u0026#34; Agent: \u0026#34;抱歉，让我改一下...\u0026#34; 有 AgentMemory 的时候，Session 1 的工具调用会被自动捕获：Agent 读了 package.json（看到了 jose 依赖），写了 src/middleware/auth.ts（用 jose 实现 JWT），你的偏好被提取为一条语义记忆。当 Session 2 开始时，AgentMemory 的 memory_recall 自动召回相关上下文：\n1 2 3 4 5 6 7 [Session 2 开始] AgentMemory 注入: - 项目使用 jose 进行 JWT 验证（来源: Session 1, 强度: 8/10） - auth 中间件位于 src/middleware/auth.ts（来源: Session 1） - 认证模式: Bearer token + jose jwtVerify（来源: Session 1） 你: \u0026#34;给 API 加个鉴权中间件\u0026#34; Agent: import { jwtVerify } from \u0026#39;jose\u0026#39; // 正确 这不只是\u0026quot;记住了一个库名\u0026quot;。AgentMemory 记住的是结构化的事实——哪个文件负责什么，项目用了哪些技术栈，你做过什么决策，甚至 Agent 上次犯了什么错。这些记忆经过压缩、索引、衰减，以最小的 token 开销注入到新会话的上下文中。\nWhat：AgentMemory 是什么 一句话：AgentMemory 是一个自托管的持久记忆引擎，它自动捕获 AI 编程智能体的工具调用，将其压缩为结构化记忆，并通过混合检索在未来的会话中精准召回。\n它不是一个向量数据库，不是一个 RAG 框架，也不是一个 prompt 管理工具。它是一个完整的记忆系统——从数据捕获到压缩存储，从索引构建到检索融合，从单 Agent 到多 Agent 协调，从会话级到项目级的记忆管理。\n技术上，AgentMemory 是一个运行在 iii-engine 之上的 TypeScript 服务。iii-engine 是一个轻量级的分布式运行时，提供函数注册、KV 状态管理、HTTP/WebSocket 触发器、分布式追踪等原语。AgentMemory 在其之上注册了 123 个函数、34 个状态域、53 个 MCP 工具，构成了一个完整的记忆引擎。整个系统零外部依赖——不需要 PostgreSQL，不需要 Redis，不需要 Qdrant，npx @agentmemory/agentmemory 启动即可。\n图：AgentMemory 的分层架构 —— 顶层多种 Agent 通过 MCP 协议和生命周期钩子接入，中间是基于 iii-engine 的记忆引擎，底层是 BM25 + 向量 + 知识图谱的混合检索，所有数据本地持久化。\n三个关键词定义了 AgentMemory 的独特性：\n自动捕获：通过 12 个生命周期钩子（SessionStart、PostToolUse、Stop 等）自动从 Agent 的工具调用中提取观察，不需要用户手动记录 混合检索：BM25 关键词搜索 + 向量语义搜索 + 知识图谱实体遍历，用 Reciprocal Rank Fusion 融合三路结果 Agent 无关：支持 Claude Code、Cursor、Codex CLI、Gemini CLI、Cline、Windsurf 等 12+ 种 Agent，通过 MCP 协议统一接入 Why：为什么现有方案不够 CLAUDE.md：静态文件扛不住动态世界 CLAUDE.md（或类似的 .cursorrules、AGENTS.md）是目前最常见的 Agent 记忆方案。它的本质是一个手工维护的静态文件，每次会话时整体注入上下文窗口。\n问题在于，这种方案的 token 开销与信息量线性增长，且没有选择性。240 条观察之后，一个 CLAUDE.md 会膨胀到 22,000+ token。但某次会话可能只需要其中 5% 的信息——你在改认证模块，不需要看数据库迁移的记录。静态文件做不到按需召回。\n而且，维护这个文件本身就是负担。你在 Session 1 里做了一个架构决策，得记得手动把它写进 CLAUDE.md。你不会记得的。结果就是这个文件要么过时，要么遗漏，要么两者兼有。\nAgentMemory 的做法是自动捕获 + 按需召回。Agent 的每一次工具调用（读文件、写代码、执行命令）都被 PostToolUse 钩子捕获，压缩为结构化的观察记录，索引到混合搜索引擎中。下一次会话时，系统根据当前查询的语义，从数千条记忆中精准召回最相关的 5-10 条，token 开销控制在约 1,900 token——不到 CLAUDE.md 的十分之一。\n向量数据库 + RAG：检索不是记忆 另一种常见思路是用向量数据库做 RAG。把会话历史切成 chunk，嵌入后存进 Qdrant 或 Pinecone，每次会话时用 query 做语义检索。\n这种方案解决了\u0026quot;按需召回\u0026quot;的问题，但遗漏了记忆系统的其他维度：\n没有压缩。一次工具调用的原始输出可能是 5,000 token 的文件内容，但真正有价值的信息是\u0026quot;这个文件实现了 JWT 验证，用的是 jose 库\u0026quot;——30 个 token。如果你把原始输出直接嵌入，检索到的内容里 99% 是噪声。AgentMemory 用 LLM 或合成压缩把每条观察压缩为结构化的事实列表（facts）、关键概念（concepts）和重要性评分（importance 1-10），信噪比提升一个数量级。\n没有衰减。人类记忆会遗忘，这不是缺陷，是特性。三个月前的一次 debug 经历，如果之后再也没被提起，它的重要性应该自然降低。向量数据库不会做这件事——一条记录存进去就永远在那里，和新记录有同等的检索权重。AgentMemory 实现了基于 Ebbinghaus 遗忘曲线的记忆衰减：频繁被召回的记忆强度增加，长期未被访问的记忆自动淡出，矛盾的记忆被新版本取代。\n没有结构。向量检索是一维的——给你最相似的 K 条记录。但记忆是有结构的。\u0026ldquo;auth.ts 使用了 jose 库\u0026quot;和\u0026quot;jose 库实现了 JWT 验证\u0026quot;和\u0026quot;JWT 验证是认证模块的核心\u0026quot;之间存在图关系。当你搜索\u0026quot;认证\u0026quot;时，你需要的不只是包含\u0026quot;认证\u0026quot;这个词的记录，还有通过实体关系链接到认证概念的所有相关记忆。AgentMemory 的知识图谱检索通过 BFS 遍历提供了这个能力。\n外部依赖。Qdrant、Pinecone、pgvector——每一个都是你需要部署、维护、付费的外部服务。AgentMemory 的向量索引是内存中的，BM25 索引是本地的，知识图谱是 KV 存储的。零外部依赖，一个命令启动。\nmem0 / MemGPT：不同的权衡 mem0 和 Letta（前身 MemGPT）是两个有影响力的 AI 记忆项目。\nmem0 提供了一个记忆 API 层，支持向量 + 图检索。但它依赖外部存储（Qdrant 或 pgvector），且没有 Agent 生命周期的自动捕获——你需要手动调用 API 来存储和检索记忆。它更像一个通用的记忆存储服务，而不是一个与编程 Agent 深度集成的记忆引擎。\nMemGPT/Letta 走了另一条路：它把记忆管理放进了 Agent 的推理循环本身，让 LLM 自己决定何时存储、何时检索。这很优雅，但有一个根本问题——它需要 LLM 调用来驱动记忆操作，token 开销显著。而且它是一个完整的 Agent 运行时，不是一个可以插入已有 Agent 的记忆层。\nAgentMemory 的定位不同：它是一个外挂式记忆引擎，通过 MCP 协议和生命周期钩子无侵入地接入任何 Agent。不改 Agent 代码，不换 Agent 运行时。你用 Claude Code 还是 Cursor 还是 Codex CLI，AgentMemory 都是同一个服务，通过标准化的 MCP 接口提供记忆能力。\nHow：AgentMemory 怎么做到的 四层记忆模型：从神经科学到工程实现 AgentMemory 的记忆架构不是凭空设计的，它借鉴了认知科学中关于人类记忆的分层模型。\n图：四层记忆流水线 —— 200 次工具调用的原始观察（~50,000 token）经过压缩、提取、固化，最终沉淀为 3-5 条持久记忆（~1,900 token），每一层都在做信息的抽象和浓缩。\n工作记忆是最底层，对应 Agent 的原始工具调用。当 Agent 读了一个文件、写了一段代码、执行了一个命令，PostToolUse 钩子会捕获工具名、输入、输出，生成一条 RawObservation。这些观察有一个 5 分钟的去重窗口——如果 Agent 在短时间内多次读同一个文件，只会保留一条。去重用 SHA-256 哈希实现，避免重复存储。\n情景记忆是对单次会话的压缩。当会话结束时（Stop 钩子触发），系统把这个会话的所有观察压缩为一个摘要——发生了什么，做了哪些决策，最终结果是什么。这就像你回忆\u0026quot;上周二那次 debug\u0026quot;时记住的不是每一行代码，而是\u0026quot;发现了认证模块的一个竞态条件，用互斥锁修复了\u0026rdquo;。\n语义记忆是从多次会话中提取的持久事实。\u0026ldquo;这个项目用 jose 做 JWT 验证\u0026rdquo;、\u0026ldquo;数据库迁移脚本在 db/migrations/ 目录下\u0026rdquo;、\u0026ldquo;团队偏好函数式风格\u0026rdquo;——这些不属于某一次会话，它们是跨会话的知识。系统通过 mem::consolidate（巩固）操作，把相关的观察合成为长期记忆，并赋予强度评分（strength 1-10）。\n程序记忆是最高层的抽象——工作流模式和决策模板。\u0026ldquo;每次改 API 接口都要同步更新 OpenAPI spec\u0026rdquo;、\u0026ldquo;遇到类型错误先检查 tsconfig 的 strict 配置\u0026rdquo;。这些是从多次观察中归纳出的行为模式。\n这四层不是独立的桶，而是一条流水线。数据从工作记忆流向程序记忆，每一层都在做压缩和抽象。最终，一次包含 200 个工具调用的会话可能只留下 3-5 条语义记忆和 1-2 条程序记忆，token 开销从数万压缩到不足两千。\n每条记忆还携带完整的溯源信息。Memory 类型的 sessionIds 字段记录了这条记忆来自哪些会话，version 字段跟踪演化历史（当一条新记忆取代旧记忆时，旧版本被标记为 isLatest: false）。你可以追溯任何一条记忆的来源，这在团队协作场景下尤其重要。\n压缩：从 5000 token 到 30 token 图：压缩的两条路径 —— LLM 压缩提取结构化事实（更精准，有 token 成本），合成压缩用规则提取关键词（即时完成，零成本），两者输出同一类型，下游透明。LLM 超时时自动降级到合成路径。\n记忆系统的核心不是存储，是压缩。一次 PostToolUse 捕获的原始数据可能是这样的：\n1 2 3 4 5 6 { \u0026#34;toolName\u0026#34;: \u0026#34;Read\u0026#34;, \u0026#34;toolInput\u0026#34;: { \u0026#34;file_path\u0026#34;: \u0026#34;src/middleware/auth.ts\u0026#34; }, \u0026#34;toolOutput\u0026#34;: \u0026#34;// JWT verification middleware\\nimport { jwtVerify } from \u0026#39;jose\u0026#39;\\n...\u0026#34; // ... 5000 token 的文件内容 } AgentMemory 提供两种压缩路径：\nLLM 压缩（AGENTMEMORY_AUTO_COMPRESS=true）：把原始观察发给 LLM，用 XML schema 约束输出格式，提取结构化的事实：\n1 2 3 4 5 6 7 8 9 10 11 12 { \u0026#34;type\u0026#34;: \u0026#34;file_read\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;Read JWT auth middleware\u0026#34;, \u0026#34;facts\u0026#34;: [ \u0026#34;auth.ts implements JWT verification using jose library\u0026#34;, \u0026#34;uses jwtVerify() for token validation\u0026#34;, \u0026#34;extracts user ID from token payload\u0026#34; ], \u0026#34;concepts\u0026#34;: [\u0026#34;JWT\u0026#34;, \u0026#34;jose\u0026#34;, \u0026#34;authentication\u0026#34;, \u0026#34;middleware\u0026#34;], \u0026#34;files\u0026#34;: [\u0026#34;src/middleware/auth.ts\u0026#34;], \u0026#34;importance\u0026#34;: 7 } 合成压缩（默认，零 token 开销）：不调用 LLM，而是用 Porter 词干提取、关键词抽取、工具名推断等规则来生成摘要。质量比 LLM 低，但完全免费且瞬时完成。\n两种路径都输出同一个 CompressedObservation 类型，下游的索引和检索逻辑对压缩方式透明。系统还实现了弹性降级——如果 LLM provider 超时或触发限流，自动回退到合成压缩，不丢数据。\n混合检索：三路信号的融合 图：三路检索信号的融合 —— BM25 负责精确关键词匹配，向量检索负责语义相似度，知识图谱通过实体 BFS 遍历发现间接关联，三路排名经 RRF（k=60）融合后输出最终结果，每个会话最多贡献 3 条。\n这是 AgentMemory 技术上最有意思的部分。记忆的检索不是单一的向量相似度搜索，而是三路信号的融合。\nBM25 关键词检索是第一路信号。它基于经典的词频-逆文档频率模型，用 Porter 词干提取做 tokenization。值得注意的是，AgentMemory 的 BM25 实现对多语言做了专门的处理——支持希腊语、西里尔字母、希伯来语、阿拉伯语、拉丁重音字符，以及 CJK（中日韩）分词（通过 Jieba 和 tiny-segmenter）。它还内置了同义词扩展：搜索\u0026quot;auth\u0026quot;会同时匹配\u0026quot;authentication\u0026quot;和\u0026quot;JWT\u0026quot;。\nBM25 的优势是精确匹配。当你搜索\u0026quot;jose library\u0026quot;时，包含这个确切词组的记忆会被高权重召回。这是向量检索做不好的——向量模型可能认为\u0026quot;jose library\u0026quot;和\u0026quot;JWT validation package\u0026quot;语义相似，但如果你就是在找这个特定的库名，BM25 更可靠。\n向量语义检索是第二路信号。它把查询和所有记忆嵌入到同一个向量空间，用余弦相似度排序。这解决了 BM25 的短板——语义等价但措辞不同的记忆。你搜\u0026quot;认证\u0026quot;，能召回包含\u0026quot;auth\u0026quot;、\u0026ldquo;鉴权\u0026rdquo;、\u0026ldquo;JWT 验证\u0026quot;的记忆。\nAgentMemory 支持 6 种嵌入 provider，按优先级自动检测：\nProvider 模型 维度 成本 Local (@xenova/transformers) all-MiniLM-L6-v2 384 免费，离线 OpenAI text-embedding-3-small 1536 $0.02/1M token Gemini gemini-embedding-001 768-3072 有免费额度 Voyage AI voyage-code-3 1024 代码优化 Cohere embed-english-v3.0 1024 有试用额度 OpenRouter 代理任意模型 可变 可变 一个精妙的细节是维度守卫（Dimension Guard）：如果某条记忆的嵌入维度和当前 provider 的维度不匹配（比如你从 OpenAI 换到了本地模型），系统会跳过这条记忆的向量检索，而不是让错误的余弦相似度污染结果。这条记忆仍然可以通过 BM25 被召回。\n知识图谱检索是第三路信号。系统从观察中提取实体（文件、技术、模式、人名）和关系（auth.ts --implements--\u0026gt; JWT validation --uses--\u0026gt; jose），构建一个知识图谱。检索时，先从查询中提取实体，然后做 1-2 跳的 BFS 遍历，找到所有关联实体对应的记忆。\n图谱检索的价值在于间接关联。你搜\u0026quot;安全\u0026rdquo;，BM25 和向量可能召回了\u0026quot;JWT 验证\u0026quot;，图谱遍历进一步找到了\u0026quot;jose 库的版本升级\u0026quot;和\u0026quot;CORS 配置\u0026quot;——这些记忆不包含\u0026quot;安全\u0026quot;这个词，语义距离也不近，但通过实体关系链接到了安全概念。\n三路信号用 Reciprocal Rank Fusion (RRF) 融合：\n1 2 3 score(memory) = w_bm25 × 1/(k + rank_bm25) + w_vector × 1/(k + rank_vector) + w_graph × 1/(k + rank_graph) 其中 k=60 是 RRF 的平滑参数。默认权重 w_bm25=0.4, w_vector=0.6（图谱权重隐式为 0.3）。RRF 的优势在于它融合的是排名而不是分数——不同检索引擎的分数分布和数值范围差异很大，直接加权不稳定。用排名来融合则避免了这个校准问题。\n最后还有一个会话多样性约束：每个会话最多贡献 3 条检索结果。这防止了一个特别长的会话（比如一次 6 小时的重构）主导所有检索结果。\n生命周期钩子：无侵入的数据捕获 AgentMemory 不改 Agent 的代码。它通过 12 个生命周期钩子从外部监听 Agent 的行为：\n钩子 触发时机 捕获内容 SessionStart 会话开始 项目路径、工作目录 PostToolUse 工具调用完成 工具名、输入、输出 PostToolUseFailure 工具调用失败 错误信息、堆栈 Stop 会话即将结束 触发摘要生成、图谱提取 SubagentStart/Stop 子 Agent 启动/完成 子任务描述、结果 其中最核心的是 PostToolUse——这是记忆数据的主要来源。每次 Agent 读文件、写代码、执行命令，这个钩子都会被触发。捕获的原始数据经过三道处理：\n隐私过滤：自动剥离 API 密钥、密码、\u0026lt;private\u0026gt; 标签包裹的内容 去重：SHA-256 哈希对比最近 5 分钟内的观察，跳过重复的 图片提取：如果工具输出包含图片数据，单独存储并设置配额管理 处理后的数据进入压缩 → 索引 → 存储的流水线。整个过程对 Agent 透明——Agent 不知道也不需要知道有一个记忆系统在后台工作。\n钩子的传输有两种方式：对于 Claude Code 等支持 CLI 钩子的 Agent，通过 plugin/ 目录下的 npm 脚本实现；对于支持 MCP 的 Agent，通过 MCP Server 的 53 个工具暴露记忆功能。两种方式最终都汇入同一个 mem::observe 端点。\n记忆衰减与巩固：对抗无限增长 图：记忆的生命周期 —— 新记忆进入系统后，被频繁召回的记忆强度上升（巩固），长期未访问的记忆强度自然衰减（Ebbinghaus 曲线），矛盾的旧记忆被新版本取代，低于阈值的记忆在下一轮巩固周期中被清除。\n记忆系统面临一个根本性的矛盾：你希望记住足够多的信息，但上下文窗口是有限的，存储也是有限的。如果只做加法不做减法，系统最终会被噪声淹没。\nAgentMemory 用三种机制对抗增长：\nTTL 过期：每条记忆可以设置 forgetAfter 时间戳。对于临时性的上下文（比如\u0026quot;正在做 feature-x 分支的开发\u0026quot;），系统会自动设置过期时间。到期后记忆从索引中移除。\nEbbinghaus 衰减：借鉴了间隔重复（spaced repetition）的理念。每次一条记忆被检索召回，它的强度会增加。长期未被访问的记忆，强度自然降低。当强度低于阈值时，记忆进入\u0026quot;可遗忘\u0026quot;状态，在下一次巩固周期中可能被清除。这模拟了人类记忆的核心特征——用进废退。\n矛盾检测：当一条新记忆和已有记忆的 Jaccard 相似度超过 0.9 但内容不同时，系统识别为矛盾。新记忆取代旧记忆，旧版本被标记为 isLatest: false 保留在历史中。例如，Session 1 记录了\u0026quot;项目用 Express 做 HTTP 服务\u0026quot;，Session 5 记录了\u0026quot;项目迁移到了 Fastify\u0026quot;——后者会取代前者成为当前事实。\n巩固（Consolidation） 是与衰减相反的操作。它把多条相关的低层级观察合成为一条高层级记忆。例如，三个不同会话中分别出现了\u0026quot;改了 auth.ts 的 JWT 验证\u0026quot;、\u0026ldquo;修复了 auth.ts 的 token 过期处理\u0026rdquo;、\u0026ldquo;给 auth.ts 加了刷新 token 逻辑\u0026rdquo;，巩固后会生成一条语义记忆：\u0026ldquo;src/middleware/auth.ts 是项目的核心认证模块，实现了 JWT 验证、token 过期处理和刷新 token 逻辑\u0026rdquo;。\n记忆槽位：可编辑的固定上下文 除了自动管理的记忆外，AgentMemory 还提供了一套记忆槽位（Memory Slots） 机制（AGENTMEMORY_SLOTS=true）。这是自动记忆和手动配置的折中方案。\n槽位 作用域 容量 用途 persona 全局 1000 字符 Agent 角色定义 user_preferences 全局 2000 字符 编码风格偏好 project_context 项目 3000 字符 架构、构建命令 pending_items 项目 2000 字符 未完成的 TODO guidance 项目 1500 字符 下次会话的建议 session_patterns 项目 1500 字符 反复出现的行为模式 槽位既可以手动编辑，也可以开启自动反思（AGENTMEMORY_REFLECT=true）。自动反思会在会话结束时扫描本次观察，自动更新相关槽位——比如把新发现的 TODO 追加到 pending_items，把本次涉及的文件更新到 project_context。\n这个设计有意思的地方在于，它用固定容量的槽位解决了 CLAUDE.md 无限膨胀的问题。每个槽位有字符上限，超出时系统会压缩旧内容而不是无限追加。你得到的是一个始终保持紧凑的项目画像，而不是一份越来越长的历史记录。\n多 Agent 协调：租约、信号与网格同步 当多个 Agent 同时在一个项目上工作时（比如一个 Claude Code 实例在写代码，一个 Codex CLI 实例在跑测试），记忆系统需要解决协调问题。\n租约（Leases） 实现了互斥访问。当一个 Agent 开始处理某个 Action 时，它通过 mem::lease-acquire 获取租约，其他 Agent 看到这个 Action 被锁定就会跳过。租约有 TTL（默认 10 分钟），超时自动释放，防止 Agent 崩溃导致死锁。\n信号（Signals） 实现了 Agent 间通信。一个 Agent 发现了 bug，可以通过 mem::signal-send 通知其他 Agent。信号是点对点的，消费后删除。\n网格同步（Mesh Sync） 实现了多实例的数据复制。如果你在两台机器上分别运行了 AgentMemory 实例，可以通过 mem::mesh-sync 在它们之间同步记忆、Action 和知识图谱。冲突解决策略是 Last-Write-Wins，基于 updatedAt 时间戳。\n设计决策与权衡 为什么选 iii-engine 而不是自建运行时 AgentMemory 选择在 iii-engine 上构建，而不是从零搭建一个 Express + Redis + PostgreSQL 的服务。这个决策的代价是引入了一个不太知名的运行时依赖，好处是获得了一系列开箱即用的基础设施：\n函数注册和路由——不用写 Express 路由 KV 状态管理——不用部署 Redis 分布式追踪——不用配置 Prometheus + Jaeger Cron 调度——iii worker add iii-cron 一行命令添加定时任务 多实例同步——iii worker add iii-pubsub 添加发布订阅 如果自建运行时，这些基础设施每一项都是一个独立的工程问题。iii-engine 提供了一个统一的抽象层，让 AgentMemory 的代码可以专注于记忆逻辑本身。\n为什么默认不开启 LLM 压缩 AGENTMEMORY_AUTO_COMPRESS 默认是 false，这意味着默认使用合成压缩（基于规则的关键词提取）而不是 LLM 压缩。\n这是一个务实的权衡。LLM 压缩的质量显著高于合成压缩——它能提取语义层面的事实和关系，而不只是关键词。但它有三个问题：\n成本：每次 PostToolUse 触发一次 LLM 调用，一个活跃的开发会话可能产生数百次工具调用 延迟：LLM 调用增加了每次观察的处理时间，可能拖慢 Agent 的响应 依赖：需要配置 LLM provider 的 API key 默认关闭 LLM 压缩意味着 AgentMemory 的基线体验是零成本、零配置的。合成压缩虽然粗糙，但配合 BM25 索引仍然能提供有意义的检索能力。用户可以根据需要手动开启 LLM 压缩来获得更高质量的记忆。\n为什么用 RRF 而不是学习排序 混合检索的融合策略有很多选择：加权求和、学习排序（Learning to Rank）、RRF。AgentMemory 选择了 RRF，因为它不需要训练数据。\n学习排序需要大量的 (query, relevant_docs) 标注对来训练一个排序模型。对于一个自托管的、隐私优先的记忆系统，这些标注数据不存在——每个用户的记忆内容完全不同。RRF 是一个无参数（或近似无参数，只有一个 k=60 的平滑常数）的融合方法，它只假设\u0026quot;排名靠前的结果更可能相关\u0026quot;，对不同检索引擎的分数分布不做任何假设。\n在 AgentMemory 的自评测试中，混合检索在 LongMemEval-S（ICLR 2025）上达到了 95.2% 的 R@5 召回率。这个数字说明 RRF 这种简单策略在记忆检索场景下已经足够好了。\n什么时候用 AgentMemory，什么时候不用 适合的场景：你日常使用 AI 编程助手（Claude Code、Cursor、Codex CLI 等）；你的项目有一定规模，上下文不可能在一次会话中全部给出；你需要跨会话的连续性——今天的决策明天不应该被推翻；你在团队中使用多个 Agent，需要它们共享上下文。\n不适合的场景：你只是偶尔用一下 AI 助手做简单问答（没有足够的观察来构建有意义的记忆）；你的项目很小，CLAUDE.md 就能覆盖所有上下文；你对数据隐私有极端要求，不能接受任何形式的本地存储（虽然 AgentMemory 是自托管的，但它确实在磁盘上持久化了会话数据）；你需要的是代码搜索而不是会话记忆（那是另一类工具的领域）。\n一个值得关注的数字：AgentMemory 每次会话注入的记忆上下文约 1,900 token，不到一个膨胀后的 CLAUDE.md 的十分之一。由于默认使用合成压缩和本地嵌入，日常运行不产生额外的 LLM 调用开销。\n回到开头 AI 编程助手正在变成开发者的日常工具。但\u0026quot;每次会话都是白纸\u0026quot;这个问题，像一堵透明的墙，限制了它们真正成为协作者的可能性。\n你不会接受一个每天早上都失忆的同事。你也不应该接受一个每次会话都失忆的 Agent。\nAgentMemory 做的事情，是在 Agent 和项目之间建立一层持久的、结构化的、可检索的记忆。它自动捕获 Agent 的行为轨迹，压缩为结构化的事实和模式，通过三路混合检索在未来的会话中精准召回。记忆会衰减、会巩固、会演化，就像人类的记忆一样。\n它的核心主张很简单：AI Agent 的记忆不应该是人类的负担。 不应该是你手动维护的一个 markdown 文件，不应该是每次会话开头的一段复制粘贴。它应该是自动的、精准的、低成本的。工具应该记住和它一起工作过的人，而不是每次都像初次见面。\n","permalink":"https://luoyuxia.github.io/posts/%E5%BD%93-ai-agent-%E6%9C%89%E4%BA%86%E8%AE%B0%E5%BF%86agentmemory%E4%B8%80%E4%B8%AA%E4%B8%BA%E7%BC%96%E7%A8%8B%E6%99%BA%E8%83%BD%E4%BD%93%E8%AE%BE%E8%AE%A1%E7%9A%84%E6%8C%81%E4%B9%85%E8%AE%B0%E5%BF%86%E5%BC%95%E6%93%8E/","summary":"AgentMemory 是一个为 AI 编程智能体设计的持久记忆引擎。它用四层记忆模型模拟人类认知，通过 BM25 + 向量 + 知识图谱的混合检索实现跨会话记忆，支持 12+ 种 Agent，零外部依赖，本地运行。本文从 What / Why / How 三个维度分析这个项目的设计哲学和关键技术。","title":"当 AI Agent 有了记忆：AgentMemory，一个为编程智能体设计的持久记忆引擎"},{"content":"Ray：为 AI 工作负载设计的分布式 Python 运行时 从一个真实问题说起 假设你在训练一个大模型。你的数据预处理管道需要对 1000 万条文本做 tokenize、清洗、采样；你的超参搜索要同时跑 200 组实验；训练完之后，你需要把模型部署成一个在线服务，自动伸缩应对流量高峰。\n这三件事——数据处理、训练调参、模型服务——传统上需要三套不同的基础设施：数据管道用 Spark，超参搜索用自己攒的脚本跑多进程，模型服务用 TensorFlow Serving 或 Triton。三套技术栈，三种抽象，三组运维。\n问题不止是工具碎片化。更深层的矛盾是：Python 是 AI 的母语，但 Python 天生是单机的。 GIL 让多线程形同虚设，multiprocessing 的 IPC 开销巨大，而且一旦你想跨机器——对不起，请重写你的代码，用分布式框架的 API 重新表达你的逻辑。\n想想 Spark 的用法：你得把思维翻译成 RDD 或 DataFrame 的算子链，你的 Python 函数变成了序列化的黑盒 UDF，在 JVM 和 Python 进程之间来回搬运。Dask 好一些，但调度器是单点瓶颈，而且你仍然需要用它特有的 Delayed / Futures API。\n这不应该这么难。一个 Python 函数就是一个计算单元，一个 Python 类就是一个有状态的服务。如果有一种方式，让你在函数定义上加一行装饰器，它就能自动调度到集群上的任意节点执行，返回一个引用让你异步获取结果——那分布式计算就不再是基础设施问题，而只是一个函数调用问题。\n这就是 Ray 做的事。\n一个最小的例子：感受魔法 先看一段代码，不到 10 行：\n1 2 3 4 5 6 7 8 9 10 import ray ray.init() @ray.remote def square(x): return x * x futures = [square.remote(i) for i in range(4)] print(ray.get(futures)) # [0, 1, 4, 9] @ray.remote 把一个普通函数变成了一个分布式任务。.remote() 提交任务并立即返回一个 ObjectRef（对象引用），ray.get() 阻塞等待结果。就这样——没有 RDD，没有 DataFrame，没有 executor 配置。\n但这不是语法糖。这 10 行代码触发了 Ray 底层一整套精密的机械：\n函数被序列化（pickle），存入全局控制存储（GCS） CoreWorker 构建 TaskSpecification，通过 gRPC 向本地 Raylet 请求 worker 租约 Raylet 根据资源可用性和数据局部性选择一个 worker 节点 任务被推送到目标 worker，worker 从 GCS 拉取函数字节码，反序列化参数，执行函数 返回值存入 Plasma 对象存储（大对象）或内联在 RPC 响应中（小对象） ray.get() 从本地或远程对象存储拉取结果 一个函数调用，穿越了 5 个组件、3 种 RPC、2 层存储。用户看到的是一行装饰器，系统看到的是一套完整的分布式任务生命周期。\n有状态的场景也一样自然：\n1 2 3 4 5 6 7 8 9 10 11 12 @ray.remote class Counter: def __init__(self): self.n = 0 def increment(self): self.n += 1 def read(self): return self.n counter = Counter.remote() [counter.increment.remote() for _ in range(10)] print(ray.get(counter.read.remote())) # 10 @ray.remote 加在类上，就变成了一个 Actor——一个有状态的常驻 worker 进程。所有方法调用按序到达同一个进程，状态在方法之间保持。这不是 RPC 框架——是一等公民的分布式对象模型。\nWhat：Ray 是什么 一句话：Ray 是一个把 Python 函数和类原语（function 和 class）提升为分布式计算原语（task 和 actor）的通用框架。\n它不是又一个 MapReduce。它的野心更大：提供一个通用的分布式计算底座，让任何 Python 程序——无论是数据处理、模型训练、超参搜索还是在线推理——都能透明地扩展到集群。\n技术上，Ray 是一个 Python API + C++ 引擎的混合体。用户面对的是 @ray.remote、ray.get、ray.put 这些极简 API，底层是大量 C++ 构成的分布式运行时，通过 Cython（_raylet.pyx，超过 200KB）桥接 Python 和 C++。\n架构上，Ray 由三层组成：\nRay Core——分布式运行时的基座。三个核心抽象：\nTask：无状态的远程函数调用，支持声明 CPU / GPU / 自定义资源需求 Actor：有状态的远程类实例，方法调用保序执行 Object：不可变的分布式对象，存储在共享内存（Plasma）中，通过 ObjectRef 引用 系统组件——撑起 Core 的分布式基础设施：\nGCS（Global Control Store）：集群的\u0026quot;大脑\u0026quot;，管理节点、Actor、任务、Placement Group 的元数据 Raylet：每个节点一个的守护进程，负责本地任务调度和对象管理 Plasma Object Store：每个节点一个的共享内存对象存储，基于 Apache Arrow 内存格式 CoreWorker：长期运行的 worker 进程，执行 task 和 actor 方法 Ray AI Libraries——构建在 Core 之上的 AI 工具库：\nRay Data：分布式数据处理，可处理 TB 级数据集 Ray Train：分布式训练，支持 PyTorch DDP / DeepSpeed / Horovod Ray Tune：超参搜索，20+ 搜索算法 Ray Serve：模型服务，支持自动伸缩和模型组合 Ray RLlib：强化学习，50+ 算法 这个分层设计的关键在于：AI Libraries 是 Core 的用户，不是 Core 的特例。 任何人都可以用同样的 Task / Actor / Object 原语构建自己的分布式应用。Ray 不是一个特化的数据处理引擎或训练框架——它是一个通用的分布式计算操作系统。\nWhy：已有工具哪里不够 Python 的并行困境 Python 的 GIL 让多线程对 CPU 密集型任务形同虚设。multiprocessing 能绕过 GIL，但每个进程有独立的内存空间，数据传递靠序列化——把一个 1GB 的 NumPy 数组从一个进程搬到另一个，你得先 pickle 成字节流，再 unpickle 回来，内存翻倍，速度砍半。\n更致命的是，multiprocessing 止步于单机。跨机器？请自己搭 RPC，自己管连接，自己处理故障。\nRay 的 Plasma 对象存储从根本上解决了这个问题：对象存在共享内存里，多个 worker 进程通过 memory-mapped file 零拷贝访问同一块数据。一个 1GB 的 NumPy 数组，ray.put() 放进去之后，所有同节点的 worker 直接读，不需要任何序列化。跨节点？对象通过 gRPC 传输，但只传一次，然后缓存在本地 Plasma 里，后续访问同样零拷贝。\nSpark 的语义鸿沟 Spark 是为 ETL 和 SQL 分析设计的——它的抽象是 DataFrame 和 RDD，它的执行模型是 BSP（Bulk Synchronous Parallel，批同步并行）。这在数据分析场景下工作得很好，但在 AI 场景下处处碰壁：\n有状态计算：训练一个模型需要在迭代间保持参数状态。Spark 的核心抽象更偏批处理数据流，跨迭代的训练状态通常需要通过缓存、checkpoint 或外部存储显式管理，不像 Ray Actor 那样把长生命周期进程作为一等抽象。 异构资源：AI 工作负载同时需要 CPU（数据预处理）和 GPU（模型推理/训练），且两者的比例随任务变化。Ray 的 task/actor 资源声明（num_gpus=0.3）更贴近 Python 函数和服务化 AI 工作负载的粒度，资源分配可以细到单个函数调用级别。 任务粒度：超参搜索需要同时跑几百个独立的训练实验，每个实验持续几分钟到几小时。Spark 的 BSP 模型要求所有 task 同步推进——一个慢 task 拖慢整个 stage。 Python 开销：PySpark 的每次 UDF 调用都需要在 JVM 和 Python 进程之间序列化/反序列化数据。对于 AI 管道中频繁的模型推理调用，这个开销是致命的。 Ray 的 Actor 模型天然支持有状态计算——一个 Actor 就是一个常驻进程，参数保持在进程内存里。资源声明是细粒度的——@ray.remote(num_gpus=0.5) 意味着一块 GPU 可以同时跑两个任务。调度是异步的——200 个超参实验各自独立推进，互不阻塞。\nDask 的调度瓶颈 Dask 是 Python 生态中最接近 Ray 的工具——它也试图让 Python 透明地分布式化。但它有一个架构瓶颈：中心化调度器。所有任务的调度决策都由单个进程做出，当任务数量达到百万级时，调度器的 CPU 和内存成为瓶颈。\nRay 的调度是分层的：GCS 管全局元数据，Raylet 管本地调度。每个节点的 Raylet 独立做调度决策，只有跨节点的资源协调才需要 GCS 介入。调度压力不会全部压到单个中心调度器上，具备更好的横向扩展空间。\nHow：Ray 怎么做到的 层次化 ID 体系：每个对象都知道自己从哪来 分布式系统的第一个问题是：怎么命名一切？ 任务、Actor、对象、Job——它们之间有复杂的归属关系（一个 Job 包含多个 Actor，一个 Actor 发起多个 Task，一个 Task 产生多个 Object），你需要高效地编码这些关系。\nRay 的解法是一个层次化的 ID 编码方案：\nJobID 4 字节，由 GCS 生成；ActorID 16 字节，末尾嵌入 JobID；TaskID 24 字节，末尾嵌入 ActorID；ObjectID 28 字节，末尾嵌入 TaskID，前 4 字节是对象在该任务返回值中的索引。\n这个设计的精妙之处：给你一个 ObjectID，你不需要查任何外部存储，就能从中解析出它属于哪个 Task、哪个 Actor、哪个 Job。 一个 28 字节的 ID 编码了整条 lineage 链。这让分布式追踪、引用计数、故障恢复都变得高效——不需要额外的一次 RPC 查询归属关系，ID 本身就是答案。\n对于普通函数任务（非 Actor 方法），TaskID 中的 ActorID 部分使用 ActorID::NilFromJob(job_id) 作为 dummy ActorID，其 unique bytes 为 nil 值，末尾仍嵌入 JobID。这样所有类型的任务——普通任务、Actor 方法调用、Actor 创建任务——共享同一套 ID 布局，简化了整个系统的 ID 处理逻辑。\n集群拓扑：各组件如何协作 在一个多节点的 Ray 集群中，各组件的物理分布如下：\n关键数据流：\n控制面（细线）：GCS ↔ Raylet，通过 gRPC + Pub/Sub，传递元数据（节点状态、Actor 生命周期） 调度面：CoreWorker → Raylet，RequestWorkerLease RPC 数据面（粗线）：Plasma ↔ Plasma，跨节点对象传输；Worker ↔ Plasma，同节点零拷贝 一个 Task 的完整生命周期：从 .remote() 到 ray.get() 用户写 my_task.remote(\u0026quot;Ray\u0026quot;) 这一行代码时，下面发生了什么？让我们一步步拆解。\n第一步：定义——@ray.remote 的 \u0026ldquo;记忆\u0026rdquo;\n@ray.remote 包装原始函数，生成一个 RemoteFunction 实例（定义在 python/ray/remote_function.py）。它存储了函数引用和所有用户指定的 Ray 选项（num_cpus、num_gpus 等）。关键的是，函数本身还没有被序列化或发送到任何地方——@ray.remote 只是注册，不执行。\n第二步：提交——.remote() 的异步启动\n调用 .remote(\u0026quot;Ray\u0026quot;) 时，真正的工作开始了：\n序列化函数：首次调用时，Python 函数被 pickle 成字节流，通过 gRPC 存入 GCS 的 Key-Value 存储。Key 是函数的 FunctionID。这只做一次——后续调用直接复用。\n处理参数：参数有三种传递方式：\n引用传递：参数本身是 ObjectRef，直接传递引用 内联值传递：小对象（默认 \u0026lt; 100KB）直接 pickle 后嵌入 RPC 消息 非内联值传递：大对象先 ray.put() 到 Plasma 存储，再传递生成的 ObjectRef 构建 TaskSpecification：包含函数 ID、参数列表、资源需求等全部信息。这个结构体在 C++ 层的 CoreWorker::SubmitTask 中构建。\n异步提交：TaskSpec 被异步提交给 NormalTaskSubmitter。.remote() 立即返回 ObjectRef，不等待执行。\n第三步：调度——Raylet 的资源博弈\nNormalTaskSubmitter 先等待所有 ObjectRef 参数就绪（即产生这些对象的上游任务已完成），然后向 Raylet 发送 RequestWorkerLease RPC 请求一个 worker。\nRaylet 的调度策略是这样的：NormalTaskSubmitter 首先向本地 Raylet 发送请求——所谓\u0026quot;本地 Raylet\u0026quot;就是调用 .remote() 的进程所在节点上的那个 Raylet（每个节点恰好运行一个 Raylet，CoreWorker 启动时就绑定到它，不需要寻找）。如果任务参数对象在其他节点上，也可能直接发给那个数据局部性更优的 Raylet。Raylet 检查本地资源是否满足任务需求。如果满足，分配一个 worker 返回其地址；如果不满足，Raylet 不会自己转发请求，而是回复一个 spillback 响应，告诉 NormalTaskSubmitter 应该去找哪个节点。NormalTaskSubmitter 收到后，再向目标节点的 Raylet 发起新的 RequestWorkerLease。这个过程一直持续到找到一个有资源的节点为止。\n注意：Raylet 之间不直接转发调度请求。调度重定向是\u0026quot;客户端重试\u0026quot;模式——发请求的始终是 NormalTaskSubmitter（CoreWorker 内部），Raylet 只负责告诉它该去哪。这样设计避免了中心化调度的瓶颈和 Raylet 之间的级联调用，每个 Raylet 只做本地决策，保持了调度路径的简洁。\n第四步：执行——Worker 的完整工作\n拿到 worker 租约后，NormalTaskSubmitter 向目标 worker 发送 PushTask RPC（包含 TaskSpec）。Worker 端的执行流程：\n从 Plasma 拉取所有按引用传递的参数（跨节点时，先把远程 Plasma 上的对象拉到本地 Plasma） 从 GCS Key-Value 存储拉取函数字节码，反序列化 反序列化内联参数 调用用户函数 第五步：返回——大小对象的分流\n函数执行完毕后，返回值的处理取决于大小：\n小返回值：直接内联在 PushTask RPC 响应中返回给调用者，存入调用者的 memory store 大返回值：先存入 worker 本地的 Plasma 对象存储，调用者在 ray.get() 时从远程 Plasma 拉取 这个分流策略避免了小对象走 Plasma 的额外开销（共享内存分配、IPC 通知），同时保证大对象不会撑爆 RPC 消息。\nray.get(obj_ref) 内部的逻辑：先查本地 memory store（内联返回值已经在这里了），如果没有则查本地 Plasma，如果还没有则向远程 Plasma 拉取。三层存储，逐级查找。\nGCS：集群的\u0026quot;大脑\u0026quot; GCS（Global Control Store）是 Ray 集群唯一的中心化组件——但它被精心设计为只管元数据，不管数据流。\nGCS 管理以下资源：\n节点管理（GcsNodeManager）：跟踪集群中所有节点的存活状态、资源清单、心跳监控 Actor 生命周期（GcsActorManager）：创建、调度、重启、死亡——Actor 的状态机由 GCS 全局管理 Placement Group（GcsPlacementGroupManager）：原子性地预留一组跨节点资源，支持 PACK（紧凑）和 SPREAD（分散）策略 任务元数据（GcsTaskManager）：任务状态追踪、lineage 记录（用于故障恢复时重建任务链） 函数存储：GCS 的 Key-Value 存储保存了所有 @ray.remote 函数的序列化字节码 GCS 默认使用内存存储元数据；如果需要 GCS 容错，可以配置 Redis 作为外部持久化后端。它通过 Pub/Sub 机制向各组件推送状态变更——不用轮询，订阅即可。Actor 状态变了？所有订阅者收到通知。节点挂了？GCS 广播。\n关键设计决策：GCS 不参与数据面（data plane）的任何操作。 对象存取走 Plasma，任务推送走 CoreWorker 之间的直连 RPC。GCS 只在控制面（control plane）上活跃——创建 Actor、注册节点、记录 lineage。GCS 不在数据面路径上，但默认不具备容错——GCS 数据存在内存中，GCS 进程失败会导致整个集群不可用。生产环境需要配置 HA Redis 作为 GCS 的持久化后端，GCS 重启后才能从 Redis 恢复控制面状态。\nRaylet：每个节点的\u0026quot;管家\u0026quot; Raylet 是运行在每个节点上的守护进程。它的核心职责：\n任务调度：Raylet 接收 RequestWorkerLease RPC，根据本地可用资源决定是否分配 worker。调度策略考虑三个因素：\n资源匹配：任务声明的 CPU / GPU / 自定义资源是否满足 数据局部性：任务依赖的对象是否在本节点的 Plasma 中 Placement Group 约束：是否有 gang scheduling 要求 如果本地资源不足，Raylet 返回 spillback 节点地址，由提交端（NormalTaskSubmitter）重试目标 Raylet。\nWorker 进程池：管理 worker 进程的启动、回收、健康检查。Worker 是长期运行的 Python 进程——不是每个 task 启动一个新进程，而是从池中租借一个空闲 worker，执行完毕后归还。这避免了进程启动的开销，也意味着 Python 解释器的预热（import 库等）只做一次。\n对象管理：Raylet 通过 LocalObjectManager 跟踪本节点 Plasma 中所有对象的状态——哪些是 pinned 的（不能被驱逐），哪些是 spilled 的（已溢写到磁盘），哪些可以被回收。\n整个 Raylet 采用事件驱动架构，基于 Boost.ASIO 实现异步 I/O——单线程事件循环处理所有 RPC，不依赖线程池，能够高效处理上万个并发请求。\nPlasma 对象存储：零拷贝的秘密 Plasma 是 Ray 的共享内存对象存储，源自 Apache Arrow 项目。它在每个节点上运行，通过 Unix domain socket 接受客户端连接，用单线程服务所有请求。\n核心机制是 memory-mapped file：对象存储在 /dev/shm（tmpfs）上的内存映射文件中。多个 worker 进程可以同时映射同一块共享内存，直接读取对象数据——零拷贝、零序列化。一个 worker 做 ray.put(numpy_array) 写入的 NumPy 数组，另一个 worker 做 ray.get() 读出来时拿到的是完全相同的那块内存。\n对象在 Plasma 中是不可变的。创建分两步：先 Create（分配空间），写入数据，然后 Seal（封印）。一旦 Seal，对象永远不会被修改——这是零拷贝安全的前提（如果对象可变，两个 reader 会看到不一致的数据）。\n当本地 Plasma 没有所需对象时，系统会自动从远程节点拉取。PullManager（src/ray/object_manager/pull_manager.cc）管理拉取请求，PushManager 管理推送。跨节点的对象传输走 gRPC，传输完成后缓存在本地 Plasma 中作为二级副本（Secondary Copy），供后续访问使用。\n对象溢写：内存不够时的优雅降级 真实场景中，对象存储的数据量经常超过物理内存。Ray 的解法是对象溢写（Object Spilling）——当 Plasma 内存压力超过阈值（默认 80%），自动把对象溢写到外部存储（本地磁盘或 S3），需要时再恢复。\n溢写架构被精心设计为三层，每层运行在不同线程/进程上，互不阻塞：\n检测层（Plasma Store 线程）：CreateRequestQueue 在内存分配失败时触发回调。Plasma Store 是单线程的——如果让它直接做 I/O，所有对象创建都会被阻塞。所以它只做一件事：通知 Raylet \u0026ldquo;内存紧张了\u0026rdquo;。\n编排层（Raylet 主线程）：LocalObjectManager 决定溢写什么和什么时候溢写。它采用\u0026quot;乐观批处理\u0026quot;策略：有空闲 IO Worker 就立刻溢写当前可用的对象，如果已经有溢写任务在跑，小批次就推迟——等积攒更多对象一起处理更高效。\n执行层（Python IO Worker 进程）：实际的磁盘/网络 I/O 由独立的 Python 进程执行，通过 gRPC 与 Raylet 通信。这意味着即使 S3 写入很慢，Raylet 的主事件循环也不会被阻塞——心跳、调度、其他 RPC 照常处理。\n一个值得细说的设计：对象融合（Object Fusion）。多个对象被合并写入同一个文件，每个对象前面有一个 24 字节的头部（3 个 8 字节字段：owner 地址长度、元数据长度、数据长度）。每个对象通过 URL 中的 offset 和 size 参数独立寻址：\n1 /tmp/ray/spill/ray_spilled_objects_\u0026lt;node_id\u0026gt;/\u0026lt;uuid\u0026gt;-multi-\u0026lt;count\u0026gt;?offset=\u0026lt;N\u0026gt;\u0026amp;size=\u0026lt;M\u0026gt; 融合文件用引用计数管理生命周期——只有当文件中所有对象都被释放后，文件本身才会被删除。这避免了每个对象一个文件带来的文件系统碎片和 syscall 开销。\n溢写还有两道保护线：\n被动触发：Plasma 分配失败时触发溢写，然后进入\u0026quot;宽限期\u0026quot;（oom_grace_period_s），等待溢写释放空间。宽限期过后如果仍然不够，启用 fallback allocator——用 mmap 直接在文件系统上分配内存，比共享内存慢但不会死锁。 主动触发：每秒检查一次内存使用率，超过阈值就预防性溢写，不等 OOM。每次有新对象 Seal 时也会检查。主动触发确保系统不会突然从\u0026quot;正常\u0026quot;跳到\u0026quot;OOM\u0026quot;，而是平滑过渡。 什么时候需要恢复？当有 Worker 需要访问已溢写的对象时——比如用户调用 ray.get(ref) 取值，或者某个任务的参数依赖指向了一个已溢写的对象。如果一个对象溢写后再也没人用它，它就一直待在磁盘/S3 上，等引用计数归零后被删除，永远不会读回内存。\n这里需要澄清：Plasma 不是外部存储，而是节点本地的共享内存（基于 /dev/shm），Worker 通过 mmap 零拷贝访问其中的对象。溢写到磁盘/S3 后，数据离开了内存；恢复的最终目标是把数据写入请求方节点的 Plasma，让该节点的 Worker 能通过 mmap 访问。\n恢复路径取决于存储类型：\n本地文件系统：对象只溢写在某个节点的磁盘上。如果请求方就在该节点，直接从磁盘读取写入本地 Plasma。如果请求方是远程节点，则先把请求发到溢写节点，溢写节点从磁盘读取后通过网络直接推送给请求方——不写入溢写节点自己的 Plasma，避免给溢写节点增加内存压力。请求方收到数据后写入自己的 Plasma。 S3 存储：任何节点都可以直接从 S3 读取，写入本地 Plasma，不需要经过溢写节点中转。 分布式引用计数：不依赖 GC 的对象生命周期管理 在分布式系统中，垃圾回收是一个出了名的难题。对象可能分散在多个节点上，被多个任务引用——什么时候可以安全地删除？\nRay 的解法是 ReferenceCounter（src/ray/core_worker/reference_counter.h），一个线程安全的分布式引用计数系统。它的核心思想是 ownership 模型：每个对象有一个唯一的 owner（创建它的 CoreWorker），owner 负责追踪所有引用并决定对象何时可以被删除。\n引用的来源有多种：\n本地引用：Python 代码中持有 ObjectRef 变量 提交引用：对象被作为参数传给另一个 task，task 完成前引用保持 序列化引用：对象被 pickle 进另一个对象内部（嵌套引用） 当所有引用都被释放后（本地引用删除 + 所有下游 task 完成），owner 通过 Pub/Sub 通知 Raylet 释放对象。Raylet 从 Plasma 中 unpin 对象，如果对象已溢写则加入删除队列。\n这个系统还支持 lineage pinning：即使对象本身已经被删除，如果它的创建任务的信息还被需要（比如用于故障恢复时重建对象），lineage 信息会被保留。这让 Ray 能在 task 失败时自动重新执行上游任务链来重建丢失的对象。\nActor 的状态机：5 个状态，完整的生命周期 Actor 是 Ray 中复杂度最高的抽象——它是有状态的、长期运行的、可能跨节点恢复的。GCS 用一个 5 状态的状态机管理每个 Actor 的生命周期：\nDEPENDENCIES_UNREADY：Actor 的构造函数参数中有未就绪的 ObjectRef PENDING_CREATION：依赖就绪，等待 GCS 调度到某个节点 ALIVE：正在运行，接受方法调用 RESTARTING：进程崩溃，正在另一个节点重启（如果配置了 max_restarts） DEAD：终结状态，所有排队的方法调用返回错误 默认单线程 Actor 会串行执行方法调用：同一 Actor handle 上提交的方法按顺序进入 Actor，多个调用者并发提交时，Actor 仍然一次只执行一个方法，状态更新按 Actor 端排队顺序发生。这通过 ActorTaskSubmitter 维护的序列号机制实现。如果启用 async actor、threaded actor 或 concurrency groups（max_concurrency \u0026gt; 1），则方法可以并发执行，顺序语义需要按配置理解。\nDetached Actor 是一个特殊变体——它的生命周期不绑定到创建者。普通 Actor 在创建者进程退出时会被 GCS 标记为 DEAD，但 Detached Actor 会一直存活，直到被显式 kill 或集群关闭。这对长期运行的服务（如 Ray Serve 的模型副本）至关重要。\nPlacement Group：原子性的跨节点资源预留 在分布式训练中，你通常需要保证一组 Actor 被部署到同一台物理机上（数据并行的通信开销），或者相反——分散到不同机器上（容错）。Placement Group 就是为此设计的。\n1 2 3 4 5 6 7 8 9 10 11 12 from ray.util.placement_group import placement_group # 在同一节点上预留 2 个 GPU pg = placement_group([{\u0026#34;GPU\u0026#34;: 1}, {\u0026#34;GPU\u0026#34;: 1}], strategy=\u0026#34;PACK\u0026#34;) @ray.remote(num_gpus=1) class Trainer: ... # 两个 Trainer 保证在同一台机器上 t1 = Trainer.options(placement_group=pg, placement_group_bundle_index=0).remote() t2 = Trainer.options(placement_group=pg, placement_group_bundle_index=1).remote() Placement Group 的关键语义是原子性（atomicity）：要么所有 bundle 都成功预留，要么全部失败。这就是 gang scheduling——不会出现预留了一半资源、另一半等不到的死锁。\nGCS 的 GcsPlacementGroupManager（src/ray/gcs/gcs_placement_group_manager.cc，47KB）负责全局的 Placement Group 调度。两种策略：\nPACK：尽量把所有 bundle 放在同一个节点上，减少网络通信 SPREAD：把 bundle 分散到不同节点上，提高容错性 一些值得展开的设计决策 为什么 Worker 是长期运行的进程，而不是每 Task 一个进程？ 启动一个 Python 进程的代价不容小觑：fork + exec + Python 解释器初始化 + import 常用库（NumPy、PyTorch），这个过程可能花费数秒。如果每个 task 启动一个新进程，当你提交 10000 个轻量 task 时，进程启动的总开销可能远超实际计算时间。\nRay 的 Worker Pool 预先启动一组 worker 进程，task 执行完毕后 worker 不退出，而是归还到池中等待下一个任务。只有当并发需求超过当前 worker 数量时，才会按需启动新 worker（受 max_workers 限制）。\n这也解释了为什么 Ray 的函数序列化策略是\u0026quot;首次调用时存入 GCS，后续直接拉取\u0026quot;——worker 可以缓存已经反序列化的函数，同一个函数的多次调用不需要重复 unpickle。\n为什么参数传递要区分\u0026quot;内联\u0026quot;和\u0026quot;非内联\u0026quot;？ 小对象走 RPC 内联传递，大对象走 Plasma 对象存储——这个分流策略背后的权衡是：\n小对象：如果把一个 100 字节的整数也放进 Plasma，你需要一次 IPC 通知、一次 memory map、一次 IPC 确认，开销远大于数据本身。直接塞进 RPC 消息更快。 大对象：如果把一个 1GB 的 tensor 内联到 RPC 消息里，protobuf 的序列化和 gRPC 的缓冲区管理会崩溃。放进 Plasma 后只传递引用（28 字节的 ObjectID），接收端从本地 Plasma 零拷贝读取。 阈值是可配置的，默认约 100KB。这个数字是经验值——在典型网络和 IPC 延迟下，小于此值内联更快，大于此值走 Plasma 更优。\n为什么 GCS 选择了 Pub/Sub 而不是轮询？ 在大规模集群中（数百节点、数百万对象），如果每个 Raylet 每秒向 GCS 轮询\u0026quot;有没有新的 Actor 创建？有没有节点挂掉？\u0026quot;，GCS 的 RPC 压力会是 O(节点数 × 事件类型数 × 频率)。\nPub/Sub 把这个关系反转：组件只订阅自己关心的事件频道，GCS 在状态变更时推送通知。一个节点挂掉，只有持有该节点上 Actor 引用的组件会收到通知——其他节点完全不受影响。这把 GCS 的负载从 O(N²) 降到了 O(affected)。\n什么时候用 Ray，什么时候不用 适合 Ray 的场景：\n你的 Python 程序需要跨多个 CPU 核心或多台机器并行——Ray 让你不需要重写代码 你在做 ML/AI 工作——训练、调参、推理、数据预处理都有对应的 Ray 库 你有异构资源需求——同时需要 CPU 和 GPU，需要细粒度资源分配 你需要有状态的分布式服务——Actor 模型比裸 RPC 框架更自然 你的工作负载是异步的——成百上千个独立任务需要并发执行 不适合的场景：\n纯 SQL 分析——Spark / DuckDB / Polars 在这个赛道上更成熟 小规模数据、单机场景——multiprocessing 甚至 asyncio 就够了，Ray 的运行时本身有初始化开销 实时流处理——Flink / Kafka Streams 的流处理语义（窗口、watermark、exactly-once）更完善 你的团队没有 Python——Ray 的 C++ 和 Java API 存在但远不如 Python 成熟 一些真实的数字：OpenAI 用 Ray 做 ChatGPT 的 RLHF 训练；Uber 用 Ray 做 Michelangelo 平台的 ML 基础设施；Spotify 用 Ray 做推荐系统的特征计算；Ant Group 用 Ray 支撑大规模图计算和强化学习。\n谁在用 Ray，用来做什么 理论讲完了，看看真实世界里 Ray 在干什么。\nLLM 训练：OpenAI OpenAI 用 Ray 协调 ChatGPT 的分布式训练——数千块 GPU 的任务调度、数据分发、容错恢复。Greg Brockman 的原话：\u0026ldquo;Ray was by far the winner among distributed computing solutions.\u0026rdquo; 核心价值是：研究人员本地写的代码，不用改就能提交到千卡集群跑。\n参考：How Ray, a Distributed AI Framework, Helps Power ChatGPT\n大规模批量推理：ByteDance ByteDance 用 Ray Data 对 200TB 多模态数据做离线推理。模型超过 100 亿参数，单 GPU 装不下，按层切分到 3 块 GPU 上。Ray Data 的流式执行让 CPU 预处理和 GPU 推理同时进行，不需要把 200TB 中间结果全量写磁盘——这类 CPU/GPU 流水线不是 Spark 的强项。\n参考：How ByteDance Scales Offline Inference with Multi-Modal LLMs to 200TB Data\n异构集群省成本：Uber \u0026amp; Netflix Uber 把训练管道拆成 CPU 节点（数据加载）和 GPU 节点（梯度计算），成本降低 50%。Netflix 做了同样的事——把数据预处理 offload 到独立 CPU 节点，训练吞吐量翻了 3-5 倍。本质是：用 Ray 的异构调度让 GPU 不再等 CPU。\n参考：Elastic Deep Learning with Horovod on Ray - Uber Blog、Heterogeneous Training Cluster with Ray at Netflix\nML 平台：Spotify Spotify 的 ML 基础设施原来只支持 TensorFlow。迁移到 Ray 之后，PyTorch、XGBoost、PyG（图神经网络）都能跑。一个 GNN 推荐系统从开发到上线 A/B 测试只用了 3 个月。\n参考：Unleashing ML Innovation at Spotify with Ray\n模型在线服务：蚂蚁集团 全球最大的 Ray 生产集群——24 万核。双十一峰值 137 万 TPS。用 Ray Serve 做 serverless 模型服务：用户提交模型代码，平台自动部署、隔离、弹性伸缩。\n参考：How Ant Group Uses Ray to Build a Large-Scale Online Serverless Platform\nLLM 推理引擎：vLLM vLLM 多节点推理默认使用 Ray 作为分布式执行后端。2 节点 16 卡的典型配置：tensor parallel = 8（节点内），pipeline parallel = 2（跨节点）。Ray Serve LLM 在 vLLM 之上增加了 prefix-aware routing（TTFT 降低 60%）、多 LoRA 热切换、自动伸缩。\n参考：vLLM Distributed Inference and Serving、Announcing Native LLM APIs in Ray Data and Ray Serve\nRLHF 训练：OpenRLHF 70B 模型做 RLHF 需要 4 个模型同时在线（actor / critic / reward / reference）。OpenRLHF 用 Ray 把它们分布到不同的 GPU 组——16 卡跑 vLLM 生成、16 卡跑 Actor 训练、16 卡跑 Critic 训练。Ray 负责模型间的数据协调和 GPU 资源动态分配。\n参考：OpenRLHF: An Easy-to-use, Scalable and High-performance RLHF Framework\n自动驾驶数据管道：Applied Intuition TB 级传感器日志（激光雷达、摄像头、雷达）经过 CPU 坐标变换后送入 GPU 做目标检测。Ray Data 的 streaming + 异构调度让 CPU 和 GPU worker 独立伸缩。\n参考：Ray Summit 2025 - Applied Intuition: Powering Large-Scale Batch Inference Pipelines\n一个共同的模式 这些案例的共同点是：Ray 几乎从不单独做\u0026quot;计算\u0026quot;本身——训练用 PyTorch/DeepSpeed，推理用 vLLM，数据处理用 Arrow/Pandas。Ray 做的是把这些东西粘在一起、放到集群上跑、处理故障、管理资源。\n它是分布式计算的\u0026quot;操作系统层\u0026quot;，不是\u0026quot;应用层\u0026quot;。\n回到开头 1000 万条文本的预处理、200 组超参实验、弹性伸缩的模型服务——这些任务的共同点是：它们本质上都是 Python 函数和类，只是需要在多台机器上并行运行。\nRay 做的事情，是让这个跨越变得几乎透明：@ray.remote 让函数变成 task，让类变成 actor；层次化 ID 让每个对象都能追溯 lineage；两阶段调度让任务高效地找到合适的节点；Plasma 对象存储让数据零拷贝共享；对象溢写让内存不够时优雅降级而不是崩溃；引用计数让垃圾回收在分布式环境下正确工作。\n这就是 Ray 的核心主张：分布式计算不应该要求你用另一种语言思考。\n函数就是 task，类就是 actor，变量就是 object。你写的是 Python，跑的是集群。工具应该适应程序员的思维方式，而不是反过来。\n","permalink":"https://luoyuxia.github.io/posts/ray%E4%B8%BA-ai-%E5%B7%A5%E4%BD%9C%E8%B4%9F%E8%BD%BD%E8%AE%BE%E8%AE%A1%E7%9A%84%E5%88%86%E5%B8%83%E5%BC%8F-python-%E8%BF%90%E8%A1%8C%E6%97%B6/","summary":"Ray 用一个 @ray.remote 装饰器，把普通 Python 函数变成分布式任务。但这一行装饰器背后，藏着一套精密的分布式系统：层次化 ID 体系、两阶段调度、分布式对象存储、引用计数 GC、对象溢写。本文从源码出发，拆解 Ray 的核心设计。","title":"Ray：为 AI 工作负载设计的分布式 Python 运行时"},{"content":" 本文基于伊利诺伊大学厄巴纳-香槟分校（UIUC）研究团队的论文 AgileLog: A Forkable Shared Log for Agents on Data Streams 梳理总结而成。\n一、引言：一个被忽视的基础设施缺口 在现代数据基础设施中，共享日志（Shared Log）是一个不起眼但极其关键的角色。Kafka、Pulsar、Redpanda——这些我们耳熟能详的流数据平台，其底层核心都是一个共享日志：上游 producer 将数据按序写入，下游 consumer 按需消费处理。这套架构支撑着实时分析、欺诈检测、搜索索引、ML 推理等无数关键应用，已经稳定运行了十多年。\n然而，一个新的变量正在打破这种平衡：AI Agent。\n随着 LLM 推理能力的飞速提升，AI Agent 已经不再满足于被动地接收指令。它们开始主动与数据系统交互——读取流数据来理解业务上下文，生成分析查询来回答 ad-hoc 问题，甚至直接向流中写入处理结果。Confluent、Redpanda、Lenses.io 等主流流平台都已经通过 MCP（Model Context Protocol）等机制为 Agent 提供了读写流数据的能力。\n问题是：现有的流系统是为\u0026quot;行为可预测的固定程序\u0026quot;设计的，而不是为\u0026quot;探索性的、可能犯错的、大量并发的 AI Agent\u0026quot;设计的。 当数十个 Agent 同时在 Kafka 上执行探索性查询时，你的生产级消费者的延迟可能悄然飙升 14 倍。当一个 Agent 因为 LLM 幻觉写入了错误数据时，下游所有依赖这条流的应用都会受到影响。\n来自 UIUC 的研究团队在这篇论文中提出了一个观点：共享日志，这个流数据系统的核心存储抽象，需要支持创建自身的隔离分叉（fork）。 基于这一观察，他们设计了 AgileLog——一种新型的可分叉共享日志抽象，并构建了名为 Bolt 的系统实现。\n二、问题与动机：Agent 不是\u0026quot;又一个客户端\u0026quot; 要理解 AgileLog 的价值，首先需要理解 AI Agent 与传统流数据消费者之间的本质差异。\n2.1 传统应用 vs. AI Agent 传统的流数据应用——比如一个 Flink job 或一个 Kafka consumer——是程序员预先编写好的固定逻辑。它的行为是确定的：读取哪些 topic、如何处理记录、写入什么输出，都在代码中写死。运维团队可以精确预估它的资源消耗和访问模式。\nAI Agent 则完全不同。它的行为是由 LLM 在运行时动态决定的：\n探索性：Agent 不会沿着一条固定路径执行。它可能先探测流的 schema，然后采样几条记录来理解数据格式，接着尝试一种分析方法，发现不理想后再尝试另一种。一个简单的\u0026quot;分析过去 24 小时的异常\u0026quot;指令，可能触发十几轮与流系统的交互。 不确定性：LLM 的推理并非百分百准确。Agent 生成的写入可能包含错误数据，它选择的查询路径可能效率低下，它对 schema 的推断可能有偏差。 高并发：构建和部署 Agent 的门槛远低于编写传统流处理程序。一个组织可能同时运行数十个甚至上百个 Agent，每个都在同一套流基础设施上执行各自的探索任务。 2.2 三大核心问题 论文从这些差异中提炼出三个现有流系统无法解决的核心问题：\n问题一：性能隔离（Performance Isolation）\nAgent 的探索性工作负载和生产级工作负载共享同一套基础设施。在 Kafka 这样的传统架构中，consumer 和 producer 共享同一组 broker 的 CPU、内存和磁盘 I/O。当多个 Agent 同时执行大量随机读取和探索性查询时，生产级消费者——比如一个延迟敏感的欺诈检测管道——会因为资源争抢而性能急剧下降。\n这不是一个理论问题。论文在实验中展示了具体数据：在 Kafka 上，当 Agent 分析任务与延迟敏感型工作负载同时运行时，后者的端到端延迟均值升高 14 倍，p99 延迟升高 130 倍。\n问题二：写入安全（Safe Handling of Agentic Writes）\nLLM 可能产生不准确的输出，这意味着 Agent 的写入天然带有风险。如果一个负责内容审核的 Agent 将错误的标注结果直接写入输出流，所有下游消费者都会受到影响。\n更复杂的是，Agent 天然具有多路径探索的需求。比如在内容审核场景中，多个 sub-agent（各使用不同的 LLM）可能同时分析同一条输入，产生不同的审核结果。系统需要支持在隔离环境中并行探索这些路径，最终选出最佳结果并整合到主流中。\n当前的流系统没有提供这种\u0026quot;先隔离写入 → 验证 → 再整合\u0026quot;的机制。Agent 要么直接写入主流（危险），要么写入一个完全独立的临时流（但这样就失去了与主流数据的上下文关联）。\n问题三：真实数据沙盒（Realistic Sandboxes）\n编码和测试 Agent 需要沙盒环境来注入测试事件、验证流处理器行为。当前的做法是创建完全独立的合成数据流进行测试，但这种方式缺乏真实性——合成数据无法复现生产数据中的时序模式和边界情况。\n以欺诈检测测试为例：Agent 生成的合成欺诈交易必须交织在真实交易流中才有意义，因为检测模型需要参考历史交易模式来判断。但当前没有任何流系统能创建\u0026quot;既隔离于生产流、又能看到真实生产数据\u0026quot;的沙盒。\n三、使用场景：Agent 如何与流数据交互 论文梳理了五类具体的 Agent 使用场景，这些场景不是假想的，而是来自对 Confluent、Redpanda、Lenses.io 等平台实际支持的 agentic 功能的分析。\n3.1 Agentic 数据分析 场景：让非程序员用自然语言与流数据\u0026quot;对话\u0026quot;。Agent 可以构建实时仪表盘、回答窗口化查询、计算时序指标，处理类似\u0026quot;过去 24 小时最热门的商品是什么\u0026quot;这样的 ad-hoc 问题。\nAgent 的工作模式是迭代式的：先探测流的内容和 schema，采样记录理解数据结构，然后执行分析。这涉及多轮与流系统的交互，每一轮都可能探索不同的分析路径。\n对系统的需求：仪表盘需要持续获取新记录；ad-hoc 查询只需访问特定时间点的数据。\n3.2 实时上下文获取 场景：agentic 应用需要流数据作为实时决策上下文。比如 Agent 驱动的订单履行系统需要实时库存状态，Agent 会迭代地探测和读取流来构建所需上下文。\n对系统的需求：必须能看到最新的、持续更新的数据。\n3.3 Agentic 流处理 场景：LLM Agent 充当流处理器，特别适合非结构化、异构数据。典型例子是内容审核 Agent——对输入流中的每条内容调用 LLM 判断是否违规，将结果写入输出流。\n对系统的需求：Agent 既读又写流数据，写入需要经过验证后才能影响下游。\n3.4 流应用编码与测试 场景分两部分。编码 Agent 用 LLM 生成流处理应用程序，开发过程中需反复读取流来推断 schema 和调试代码。测试 Agent 生成测试事件注入流中，验证流处理器在各种边界情况（迟到事件、格式错误、重复记录）下的行为。\n对系统的需求：测试事件必须与真实数据交错（以获得时序上下文），但不能污染生产流。\n3.5 场景与设计的映射 这些场景自然映射到了 AgileLog 提供的不同 fork 原语。在展开详细设计之前，先简要介绍四种 fork 类型：\ncFork（Continuous Fork，持续分叉）：子日志持续继承父日志的新数据，像一面\u0026quot;活镜子\u0026quot;。适合需要实时数据的场景。 sFork（Severed Fork，切断分叉）：fork 后父子完全断开，子日志是父日志在某个时间点的静态快照。适合历史查询和 what-if 分析。 promotable cFork：一种特殊的 cFork，Agent 在上面写入的数据经过验证后，可以通过 promote 操作整合到父日志中，成为正式数据。 non-promotable cFork：普通的 cFork，Agent 的写入永远是私有的，不会影响父日志。适合测试等场景。 场景 读/写 需要实时数据？ 对应的 Fork 类型 数据分析（仪表盘） 只读 是 cFork（持续读） 数据分析（ad-hoc） 只读 否 sFork（快照读） 实时上下文 只读 是 cFork 流处理（需验证） 读写 是 promotable cFork + promote 多路径探索 读写 是 多个 promotable cFork 测试 读写 是 non-promotable cFork What-if 分析 读写 否 sFork 论文的论证逻辑非常清晰：每一个设计决策都有对应的真实场景作为支撑，而不是凭空构造的抽象。\n四、AgileLog 抽象设计：给共享日志加上 fork() 4.1 核心接口 AgileLog 在传统共享日志的 append/read 接口之上，增加了四个关键操作：\n1 2 3 4 5 6 7 8 9 interface AgileLog: // 传统接口 Position append(Record r); List\u0026lt;Record\u0026gt; read(Position from, Position to); // Fork 操作 AgileLog cFork(promotable = false); // 创建持续分叉 AgileLog sFork(optional Position past); // 创建切断分叉 bool promote(); // 将 cFork 提升为父日志 void squash(); // 删除分叉 4.2 Continuous Fork：论文最核心的创新 传统数据库中的 fork 语义很简单：fork 之后父子断开，各自独立演化。但这对流数据场景不够用——流数据是持续流入的，一个在 fork 上运行仪表盘查询的 Agent，必须继续看到 fork 之后父日志上的新数据，否则仪表盘就冻结了。\nAgileLog 的 Continuous Fork（cFork）解决了这个问题：\n持续继承：cFork 不仅共享 fork 之前的历史数据，还持续继承父日志 fork 之后的所有新增记录。子日志像一面\u0026quot;活镜子\u0026quot;，实时反映父日志的更新。 私有写入：cFork 可以追加自己的记录，这些记录对父日志和父日志的消费者不可见。 线性化交错：cFork 上的私有写入与从父日志继承的记录按线性顺序交错。也就是说，如果父日志在时刻 T 追加了记录 A，cFork 在时刻 T+1 追加了私有记录 X，那么在 cFork 上读到的顺序是 \u0026hellip;A, X\u0026hellip;。 单向写隔离：父→子可见（继承），子→父不可见（隔离）。这与传统 fork 的双向隔离形成对比。 用一张图来对比 cFork 和 sFork 的区别：\n图：cFork 持续分叉 vs sFork 切断分叉 —— cFork 像一面\u0026quot;活镜子\u0026quot;持续继承父日志新数据，并支持私有写入与继承记录线性化交错；sFork 在 fork point 切断后成为静态快照。\n这种语义精确地满足了测试场景的需求：Agent 在 cFork 上注入合成的欺诈交易，这些合成数据自然地与真实交易流交错，形成一个逼真的测试环境，同时完全不影响生产流。\n4.3 Promote：从隔离到整合 cFork 提供了隔离，但有些场景需要将 Agent 的写入最终反映到主流上。AgileLog 的 promote 操作实现了这一点：\nAgent 在一个标记为 promotable 的 cFork 上执行写入 写入经过验证（人工审核或自动化检查） 验证通过后调用 promote()，该 cFork 成为新的父日志 如果验证失败，调用 squash() 丢弃 为什么不直接写临时日志再 append 到主流？因为临时日志无法提供有状态的验证——验证往往需要看到 Agent 的写入与主流原有记录交错后的完整上下文。cFork 天然提供这种上下文。\nPromote 的工作流程如下图所示：\n图：Promote 从隔离到整合的三步流程 —— Agent 在 promotable cFork 上写入后经过验证，通过则 promote() 提升为新的父日志，失败则 squash() 丢弃，主日志始终不受影响。\n但 promotable cFork 带来一个值得注意的代价：在 cFork 存活期间，父日志上超过 fork point 的位置不允许读取。 原因是 promote 会在 fork point 之后插入 Agent 的写入，导致已有记录的位置编号发生变化——如果消费者在 promote 之前读取了位置 4 的记录 E，promote 之后 E 可能跑到了位置 5，之前拿到的索引就失效了。\n这意味着 promotable cFork 存活期间，父日志的下游消费者实际上被\u0026quot;冻结\u0026quot;在 fork point，无法看到任何新数据。论文在供应链 Agent 的实验中也坦承了这一点：消费者吞吐量在 cFork 存活期间出现明显下降，直到 promote 或 squash 之后才解除阻塞并快速追赶。因此，promotable cFork 的设计隐含一个前提：验证周期应该尽量短，否则冻结窗口会对生产消费者造成可感知的影响。论文也提到，一旦 Agent 经过充分验证变得可信，可以让它直接写主流来规避这一限制。\n4.4 Severed Fork：经典语义的补充 除了 cFork，AgileLog 也提供传统的 sFork（severed fork）：fork 后父子完全断开。sFork 支持从过去的某个偏移量创建，适用于时间点查询和 what-if 分析等不需要实时数据的场景。\n五、Bolt 系统实现：四项关键技术 AgileLog 是抽象，Bolt 是实现。Bolt 面临的核心工程挑战是：如何让 fork 同时满足廉价（创建延迟低）、隔离（不影响父日志性能）和可扩展（支持大量并发 fork）三个要求。\n5.1 设计洞察：Diskless 架构是天然的 Fork 基座 Bolt 的第一个关键洞察是选择无本地磁盘的共享日志架构（Diskless Architecture）作为基础。\n传统的共享日志（如 Kafka）使用有状态的 broker，数据存储在 broker 的本地磁盘上。在这种架构上实现 fork，要么复制数据（昂贵且慢），要么在同一 broker 上共享数据（引入资源争抢，破坏性能隔离）。\n无盘架构将存储和计算分离：\nBroker 无状态：不存储任何数据在本地磁盘 共享对象存储（如 AWS S3、MinIO）：存储实际数据对象 元数据层：基于 Paxos/Raft 的容错服务，维护日志索引（从位置 offset 到对象存储中数据位置的映射）和 tail（下一个空闲位置） 图：Bolt 的 Diskless 架构 —— Broker 无状态，数据存储在共享对象存储，元数据层维护索引映射。父日志与 fork 的元数据指向相同存储对象实现零数据拷贝，运行在不同 Broker 实现性能隔离。\n这种架构为 fork 提供了天然优势：子日志的元数据可以直接指向父日志在共享存储上的相同对象，实现零数据拷贝。而 fork 被分配到独立的 broker 上服务，由于 broker 无状态且对象存储可弹性扩展，父子之间的性能隔离自然达成。\n5.2 层次化日志索引（Hierarchical Log Index, HLI） 即使不复制数据，复制元数据也可能很慢。一个包含 2500 万条记录的日志，其元数据索引的复制需要约 100ms——对于需要快速创建大量 fork 的 Agent 场景来说，这是不可接受的。\nBolt 的解决方案是完全不复制元数据。它利用了日志的一个关键特性：日志是 append-only 的，fork point 之前的索引条目永远不会改变。因此，子日志可以直接指向父日志的索引，而不是复制它。\n具体来说，HLI 的工作方式是：\n创建 fork 时，子日志 C 的索引初始化为空 map，tail 设为父日志 P 的当前 tail C 上的新增 append 记录在 C 自己的索引中记录 读取时，如果请求的位置在 C 的索引中有记录，直接返回；否则递归查找父日志 P 的索引 以下图为例，子日志 R 是从父日志 G 创建的 cFork：\n图：HLI 递归查找机制 —— 子日志 R 的索引初始为空，读取继承位置时向上递归查父日志 G 的索引；fork 创建只需设置 R.tail = G.tail，零索引拷贝，延迟仅 ~50μs。\nFork 创建时只需设置 R.tail = G.tail，不复制任何索引条目。这使得 fork 创建变成了一个常数时间操作（~50μs），与日志长度无关。\n5.3 Tail-Only Updates：实现持续继承的关键 cFork 的核心语义是持续继承父日志的新记录。一种朴素的实现（论文称为 BoltNaiveCF）是：每当父日志 P 追加一条新记录时，同步更新所有后代 D 的索引和 tail。但这有两个严重问题：\n每条记录的元数据被插入到 n 个索引中（n 为 fork 数量），内存开销线性增长 更新所有后代在父日志的 append 关键路径上，直接拉高 append 延迟 Bolt 的关键观察是：要让后代\u0026quot;看到\u0026quot;父日志的新记录，只需更新后代的 tail，而不需要复制索引条目。 tail 的更新告诉后代\u0026quot;父日志有新数据了\u0026quot;，而具体的元数据可以在读取时通过 HLI 的递归查找从父日志索引中获取。\n图：朴素方案 BoltNaiveCF 在父日志 append 时同步复制元数据到所有后代索引，内存开销 O(n × fork数) 达 4.4GB；Bolt 只更新后代 tail 而不复制索引条目，读取时通过 HLI 递归查找，内存开销降至 O(n) 仅 8MB，相差 500 倍。\n这完全消除了元数据复制：每条记录的元数据只存储在它最初被 append 的那个日志的索引中。\n5.4 Lazy Tail Tree（LTT）：从 O(n) 到 O(log n) 图：LTT 的逻辑层是继承树（父子关系），物理层是 Euler Tour 的平衡 BST。Euler Tour 的关键性质使子树映射为连续区间，支持 lazy propagation 的 O(log n) 区间更新和点查询，确保 1000 个 cFork 下父日志 append 吞吐量无退化。\n即使只更新 tail，如果每次父日志 append 都主动遍历所有后代逐一更新 tail，开销仍然是 O(n)。Bolt 的解决方案是 Lazy Tail Tree（LTT）：父日志 append 时不主动通知任何后代，只在后代真正需要时才计算其最新 tail。\nLTT 的核心是将继承树的 Euler Tour（DFS 进出序列）存储在一棵平衡 BST 中。Euler Tour 有一个关键性质：任意节点的子树在序列中对应一段连续区间。\n这样，\u0026ldquo;通知 root 所有后代 tail +1\u0026quot;就变成 BST 上的一次区间更新 [0,9]。BST 通过 lazy propagation 实现：不真正遍历所有节点，而是在中间节点打标记，只在后续访问到具体 cFork 时才向下推送。大多数 cFork 在大多数时候是空闲的，标记一直挂着，零开销。\n最终效果：无论有多少 cFork，父日志 append 的额外开销都是 O(log n)，且空闲 cFork 完全不产生任何代价。\n5.5 Promote 的元数据实现 Promote 将一个 cFork 提升为父日志。Bolt 通过元数据操作实现这一点，无需任何数据拷贝：将 cFork 中 fork point 之后的元数据条目复制到父日志中，替换对应位置的条目。\n由于只需复制 fork point 之后（而非整个历史）的元数据，这是一个合理的设计选择——fork 的存活时间通常较短，积累的元数据量有限。实验显示 promote 延迟仅为 10-100μs。\n六、实验评测：数字说话 论文在 CloudLab 集群上进行了全面评测，使用 xl170 节点、9 个 MinIO 存储节点、3 副本元数据层。以下是关键结果：\n6.1 Fork 创建延迟 Bolt 的 fork 创建延迟恒定在 ~50μs，与日志长度无关。对比方案 BoltMetaCpy（复制元数据的变体）在日志包含 2500 万条记录时需要 ~100ms，慢了三个数量级。\n更重要的是，BoltMetaCpy 在创建 fork 时会阻塞父日志的 append 操作（因为元数据复制占用了元数据层的资源），导致父日志吞吐量在 fork 创建期间明显下降。Bolt 则完全没有这个问题。\n6.2 性能隔离 论文设计了一个延迟敏感型工作负载（lc-workload），模拟生产级消费者：追加记录并读取尾部最新记录。\n与 Kafka 对比：当 Agent 分析任务同时运行时，Kafka 上 lc-workload 的延迟因 broker 资源争抢而退化 2.5 倍。在 Bolt 上，由于 Agent 在独立 broker 上操作 sFork，lc-workload 完全不受影响。\ncFork 写入隔离：即使有一个 cFork 以 13KOps/s 的速度追加记录，父日志上 lc-workload 的端到端延迟（均值和 p99）也完全不受影响。\n6.3 多 Fork 可扩展性 在单个根日志上创建 0、10、100 个 cFork 时，根日志的 append 吞吐量-延迟曲线几乎完全重合——即使 100 个 cFork 都在持续继承新记录，也没有性能退化。\n扩展到 32 个根日志（模拟 Kafka 多 partition 场景），每个根日志创建 0、10、100 个 cFork，结果同样稳定。这验证了 LTT 惰性传播机制的有效性。\n6.4 元数据内存开销 维护 1000 个 cFork（根日志包含 100 万条记录）时：\nBoltNaiveCF（朴素方案）：4.4GB——因为每条记录的元数据被复制到所有后代索引中 Bolt：仅 8MB——因为 tail-only updates 完全消除了元数据复制 相差超过 500 倍。\n6.5 三个真实 Agent 应用 论文构建了三个基于 LLM（Gemini 2.5 Pro）的真实 Agent 应用来验证 AgileLog 的端到端价值：\nIoT 分析 Agent（sFork）：在 IoT 传感器数据流上执行\u0026quot;查找前 100 万条记录中的异常\u0026quot;任务。Agent 创建 sFork，在上面自由执行多轮探索性查询。在 Bolt 上，同时运行的生产工作负载完全不受影响；在 Kafka 上，Agent 查询执行阶段的生产工作负载延迟均值升高 14 倍，p99 升高 130 倍。\n流处理测试 Agent（non-promotable cFork）：测试一个 5ms 滚动窗口的聚合流处理器。Agent 在 cFork 上注入各种边界测试用例（迟到事件、格式错误、重复记录），测试事件与真实数据自然交错。即使测试与生产工作负载同时运行，生产延迟也无任何退化。cFork 天然提供了带有真实数据上下文的沙盒环境。\n供应链管理 Agent（promotable cFork + promote/squash）：Agent 监控订单流，主动生成补货事件。它在 promotable cFork 上写入补货事件，系统运行下游消费者的副本来验证这些事件的正确性。验证通过后 promote，失败则 squash。当 Agent 犯错（注入了格式错误的事件）时，Bolt 通过 cFork 隔离避免了下游消费者的崩溃；而在 Kafka 上，错误事件直接写入主流，导致下游应用崩溃。\n七、总结 7.1 优点 问题定义精准且时机恰当。 论文抓住了 AI Agent 与数据系统交互这一趋势的关键基础设施缺口。随着 MCP 等协议的普及，Agent 操作流数据已经从\u0026quot;未来展望\u0026quot;变成了\u0026quot;当下现实\u0026rdquo;。论文的贡献不是发明一个新的 Agent 框架，而是指出底层存储抽象需要进化——这是一个更深层、更持久的洞察。\n设计抽象优雅。 cFork 的\u0026quot;单向写隔离 + 持续继承 + 线性化交错\u0026quot;语义不是简单地将数据库 fork 搬到流场景，而是深入分析了流数据的特性后设计的新抽象。特别是 continuous fork 的概念，在已有的 forkable 系统（数据库、对象存储、lakehouse）中并无直接先例。\ndiskless 洞察精妙。 选择无盘架构作为 fork 的实现基座，不是一个显而易见的决定。论文系统性地论证了为什么传统的有状态 broker 架构和镜像方案都不适合，然后展示了无盘架构如何自然地提供零数据拷贝和性能隔离。\n工程实现扎实。 HLI、tail-only updates、LTT 三项技术逐层递进地解决了 fork 创建延迟、持续继承、多 fork 可扩展性三个挑战。Euler Tour + BST + lazy propagation 的组合看似复杂，但每一步都有明确的动机和量化的收益。\n7.2 局限性与开放问题 Promotable cFork 会冻结父日志的消费者。 如前文所述，promotable cFork 存活期间，父日志上 fork point 之后的读取被阻塞，下游消费者实质上被暂停。论文的实验也证实了这会导致吞吐量下降。这要求 promotable cFork 的生命周期尽可能短——快速写入、快速验证、快速 promote 或 squash。但对于需要复杂验证（比如人工审核、长时间运行有状态消费者来检查正确性）的场景，这个冻结窗口可能难以接受。\nPromote 语义是单一且排他的。 当多个 promotable cFork 竞争时，只有第一个成功 promote 的会胜出，其余被 squash。这对于\u0026quot;从多条路径中选最优\u0026quot;的场景足够了，但不支持更复杂的合并语义——比如将多个 Agent 的互补写入合并到主流中。论文承认了这一点，指出更通用的 merge 语义不保持 linearizable ordering，且当前的用例不需要。\n读取延迟的递归开销。 HLI 的递归查找意味着深层嵌套的 fork 在读取继承记录时需要多次递归。不过论文评测显示 7 层深度时元数据查找仅慢 5.2%（约 2μs），对客户端感知的毫秒级延迟影响可忽略。\n7.3 更广阔的视角 这篇论文属于一个更大的趋势：数据系统需要为 AI Agent 重新设计。 数据库领域（Dolt、Neon）在做 database branching，对象存储（Tigris）在做 bucket forking，lakehouse（Databricks 等）在做 agentic lakehouse。AgileLog 将这一趋势推进到了流数据的核心存储层。\nAgileLog 论文的核心贡献是一个清晰的洞察：当 AI Agent 成为流数据系统的一等公民时，底层的共享日志抽象必须进化——它需要学会 fork。 论文不仅提出了这个洞察，还通过 cFork 这一新型抽象和 Bolt 系统的精巧实现，展示了如何在不牺牲性能的前提下实现廉价、隔离、可扩展的 fork。\n","permalink":"https://luoyuxia.github.io/posts/%E5%BD%93-ai-agent-%E6%88%90%E4%B8%BA%E8%B0%83%E7%94%A8%E6%96%B9%E6%88%91%E4%BB%AC%E9%9C%80%E8%A6%81%E6%80%8E%E6%A0%B7%E7%9A%84%E6%97%A5%E5%BF%97%E7%B3%BB%E7%BB%9F/","summary":"基于 UIUC 论文 AgileLog 的深度分析：当 AI Agent 成为流数据系统的一等公民，底层共享日志需要支持 forking。论文提出 Continuous Fork 新抽象和 Bolt 系统实现，通过 Diskless 架构、HLI、Tail-Only Updates、Lazy Tail Tree 四项技术实现廉价、隔离、可扩展的 fork。","title":"当 AI Agent 成为调用方，我们需要怎样的日志系统？"},{"content":"当 AI 遇上数据管道：Daft，一个多模态时代的数据引擎 从一个真实问题说起 假设你在一家 AI 公司工作。你的日常可能是这样的：从 S3 上拉取 100 万张图片做去重和清洗，为训练数据集构建嵌入索引；或者拿 10 万份 PDF 做 OCR 提取，灌进 RAG 管道；又或者对一批音频文件跑 Whisper 转录，再用 LLM 做摘要。\n这些任务有一个共同点：数据不是整齐的表格，而是图片、音频、视频、PDF 这些\u0026quot;非结构化\u0026quot;格式；处理过程不是简单的 filter 和聚合，而是下载、解码、模型推理、嵌入生成的混合体；规模从笔记本上的 1000 条原型到生产环境的数十亿条。\n用 Pandas？10 万张图就 OOM 了。用 Spark？JVM 启动慢，UDF 里的 PyTorch 模型和 JVM 进程之间来回序列化，效率极低。自己写 Python 脚本？可以，但你得手动管 batch 大小、并发数、内存上限、GPU 分配、断点续传……写到最后，你发现你在造一个分布式调度框架。\n这个问题不是你的问题。它是工具的问题。\n过去十年，数据引擎的演进——从 MapReduce 到 Spark 到 Polars——一直围绕着一个核心假设：数据是表格。行和列，数字和字符串，聚合和 JOIN。这些引擎为这类工作负载做了极致的优化。\n但 AI 时代的数据管道长得不一样。它的输入是图片、音频、PDF，它的核心操作是下载、解码、嵌入、推理、去重，它的规模跨越笔记本和集群。传统工具的假设不成立了。\nDaft 就是为了解决这个问题而生的。\n一个最小的例子：感受差异 在展开 Daft 的设计之前，先用一个最小的任务来直观感受三种工具的区别：从 S3 上读取一批图片 URL，下载图片，解码后做 resize，写回 Parquet。\nPySpark 的写法大致是这样的：你需要定义一个 UDF，在 UDF 里用 requests 下载、用 PIL 解码和 resize，然后把结果序列化成字节数组返回。Spark 引擎看到的是一个黑盒 Python 函数——它不知道里面有网络 I/O，不知道有 CPU 密集的图像解码，更不知道 resize 之后的图片已经是统一尺寸。它只能以 JVM 的分区粒度把整个函数丢给 Python worker 执行，数据在 JVM 和 Python 之间来回序列化。\nRay Data 好一些：它是 Python 原生的，没有 JVM 序列化开销。你可以用 map_batches 写一个批处理函数来完成同样的事。但问题是类似的——引擎看到的仍然是一个用户函数，它不理解函数内部的结构。你需要自己决定 batch size，自己管理并发数，自己处理 OOM。如果你想让下载和解码并行，你得自己用 asyncio 或线程池来实现。\nDaft 的写法是声明式的：col(\u0026quot;url\u0026quot;).url.download().image.decode().image.resize(224, 224)。这不是语法糖——这行表达式会被解析成一棵表达式树，优化器看得见每一步操作的语义。它会自动把 url.download()（I/O 密集，异步）和 image.decode()（CPU 密集，同步）拆成独立的 pipeline stage；自动把 image.resize() 的结果提升为连续内存的 FixedShapeImage；自动让 I/O 和 CPU 在不同的线程池上重叠执行。你不需要写任何并发代码。\n三者的本质区别不在语法繁简，而在于引擎能看见多少。Spark 和 Ray Data 看到的是一个黑盒函数，只能整体调度；Daft 看到的是一棵结构化的操作树，可以拆分、重排、分别优化。这就像手写 for 循环做 join 和让 SQL 引擎优化 JOIN 的区别——不是写法问题，是抽象层次的差距。\nWhat：Daft 是什么 一句话：Daft 是第一个把多模态数据操作——下载、解码、嵌入、推理——当作可优化的查询算子来处理的 DataFrame 引擎。\n它不是又一个 Pandas 替代品。Polars 和 DuckDB 已经把\u0026quot;表格数据的单机分析\u0026quot;做到了极致，Daft 无意在这个赛道上竞争。它瞄准的是一个不同的问题：当你的数据不只是行和列，你的操作不只是 SELECT 和 GROUP BY，而是 download、decode、embed、classify、deduplicate 的时候，谁来帮你高效地跑？\n技术上，Daft 是一个 Python API + Rust 引擎的混合体。用户面对的是熟悉的 DataFrame 接口，底层是约 70 个 Rust crate 构成的执行引擎，通过 PyO3 暴露 Python 绑定，通过 Apache Arrow FFI 实现 Rust 和 Python 之间的零拷贝数据交换。不需要 JVM，不需要 Scala，pip install daft 就能用。\n这里的架构选择值得展开说一下。Spark 选择了 JVM 作为运行时，获得了成熟的分布式生态，但也背上了 GC 停顿和 Python ↔ JVM 序列化的包袱——每次 UDF 调用都要把数据从 JVM 搬到 Python 进程再搬回来。Polars 选择了纯 Rust，性能极好但锁死在单机。Daft 走了一条中间路线：计算密集的部分用 Rust（SIMD 向量化、Tokio 异步 I/O），灵活性需要的部分留给 Python（UDF、模型推理），两者之间用 Arrow 的 C Data Interface 做零拷贝桥接。这意味着一个 PyTorch 张量和 Daft 的内部数据结构可以共享同一块内存，没有序列化开销。\n图：Daft 的混合架构 —— Python API 通过 Arrow FFI 与 Rust 引擎零拷贝交互，计算密集逻辑在 Rust 层执行，灵活性留给 Python。\n三个关键词定义了 Daft 的独特性：\n多模态：Image、Embedding、Tensor、File 是 Daft 类型系统里的一等公民，不是用 Python 对象塞进 object 列的 hack 流式执行：数据不需要全量载入内存，而是以小批次在 pipeline 里流动 笔记本到集群：同一份代码，一行 daft.set_runner_ray() 切换到分布式 Why：传统工具哪里不够 引擎看不见 AI 操作 在 Spark 或 Polars 里，当你写一个 UDF 把 URL 下载成图片再跑模型，引擎看到的只是一个黑盒函数。它不知道这个函数里有网络 I/O，有 CPU 密集的图像解码，有 GPU 密集的模型推理。它没法分别控制这三步的并发度，没法在 I/O 等待时去做 CPU 计算，更没法在 filter 掉一行之后跳过对它的下载和推理。\n这不是小问题。AI 管道的成本结构和传统 ETL 完全不同：一次 GPU 推理的开销可能是一次 filter 的 1000 倍。如果你的 filter 能淘汰 90% 的行，但引擎因为看不懂你的 UDF 而把推理放在了 filter 前面，你就白白浪费了 90% 的 GPU 算力。\nDaft 的做法完全不同。它的查询优化器理解 AI 操作的语义。\n想象一下 SQL 引擎怎么处理 SELECT * FROM t WHERE id \u0026gt; 100 JOIN ...——它知道先 filter 再 JOIN 更高效，因为它理解这两个操作的语义和代价模型。Daft 对 AI 操作做了同样的事。它的函数系统区分了两种函数类型：同步的 ScalarUDF（如 image_decode，CPU 密集）和异步的 AsyncScalarUDF（如 url_download，I/O 密集）。异步函数还会声明自己的最优 batch size（比如\u0026quot;我一次并发 64 个连接最高效\u0026quot;），优化器据此把一个大投影拆成多个独立的 pipeline stage，每个 stage 有独立的并发度和资源分配。\n举个具体的例子。用户写了这样一行表达式：\n1 col(\u0026#34;url\u0026#34;).url.download().image.decode().image.resize(224, 224) 在 Spark 或 Ray Data 里，这会被当成一个整体的黑盒函数执行——引擎给你一行 URL，你还回来一张处理好的图片，中间发生了什么引擎完全不知道。但 Daft 的优化器会把它拆成三个独立的投影节点：\n1 2 3 4 5 6 7 8 9 10 用户写的： Project(resize(decode(download(url)))) 优化器拆成： Project₃: resize(decoded_img) ← CPU 密集，Rayon 多核并行 ↑ Project₂: decode(raw_bytes) ← CPU 密集，batch size 大一些更高效 ↑ Project₁: download(url) ← I/O 密集，64 个异步并发连接 ↑ Scan: 读取 URL 列表 拆分之后，每个 stage 是一个独立的 pipeline 节点，有自己的并发策略：download 用异步 I/O 跑 64 个并发连接，不占 CPU；decode 和 resize 用 Rayon 线程池做多核并行，不等网络。三个 stage 通过异步 channel 串联，同一时刻 stage 1 在下载第 N+2 批，stage 2 在解码第 N+1 批，stage 3 在 resize 第 N 批——这就是流水线并行。\n图：传统引擎把下载-解码-resize 当成一个黑盒 UDF 整体调度；Daft 的优化器将其拆分为独立的 pipeline stage，分别控制 I/O 和 CPU 并发，还能把 filter 下推到 download 之前。\n如果你在前面加了一个 where(col(\u0026quot;size\u0026quot;) \u0026gt; 1024)，优化器还会进一步把这个 filter 推到 download 之前。最终只有通过 filter 的行才会触发网络请求。这种优化在黑盒 UDF 模型下是做不到的——引擎看不见 UDF 内部的 download 操作，自然也无法把 filter 推到它前面。\n更关键的是，由于这些操作不再是黑盒，优化器可以做传统 filter pushdown：如果你的管道里有一个 filter，Daft 会确保 filter 先执行，只有通过的行才会触发后续的下载和推理。在大规模场景下，这意味着你可能只需要处理 10% 的数据，另外 90% 连一次网络请求都不会发。\n数据不再只是表格 打开 Pandas 的 dtype 列表，你会看到 int64、float32、string、datetime……但没有 Image，没有 Embedding，没有 Tensor。这不是 Pandas 的疏忽——它设计的时候，数据就是表格。\n但今天的 AI 管道里，一行数据可能是一张图片、一段音频、一个 PDF。你需要对图片做 resize，对音频做重采样，对 PDF 做 OCR。用传统工具，你只能把这些东西塞进 Python object 列，引擎完全帮不上忙——不知道每行有多大、不知道怎么序列化、不知道怎么做向量化操作。\nDaft 的类型系统原生支持这些数据类型。这不是语法糖——它深刻地影响了物理存储层的设计。\n以图片为例。Daft 有两种图片类型：Image（变形，每张图尺寸可能不同）和 FixedShapeImage（定形，所有图一样大）。当你对一批图片做 resize(224, 224) 时，如果指定了目标模式（比如 RGB），Daft 会自动将存储格式从 Image 提升为 FixedShapeImage。这不只是类型标注的变化——物理上，数据从\u0026quot;每张图一个变长 buffer + 独立的宽高通道元数据\u0026quot;变成了\u0026quot;一整块连续内存的 FixedSizeList\u0026quot;。这带来了三个好处：后续图像操作的缓存局部性更好；查询高度/宽度/通道数变成 O(1) 的元数据操作而不是逐行扫描；最重要的是，转成 PyTorch Tensor 是零拷贝的——内存布局已经就是 Tensor 需要的 (H, W, C) 排列方式。\n在图像的内存管理上，Daft 还做了一个精巧的设计：CowImage，即 Copy-on-Write 图像缓冲区。当图像从 Arrow buffer 中读出时，CowImage 直接借用（borrow）底层内存，不做任何拷贝。只有当操作确实需要修改像素数据（比如 resize 或 crop）时，才会触发一次复制。这意味着\u0026quot;读取 → 传递 → 过滤\u0026quot;这条路径上的图像数据可以完全零拷贝地流过管道，只有真正需要变换的那些行才会分配新内存。\n规模跨度太大 数据科学家的日常是这样的：先在笔记本上拿 1000 条数据验证想法，确认可行后放到生产环境跑 1 亿条。这两个阶段之间，传统工具要求你换一套技术栈——从 Pandas 换到 Spark，从本地文件换到 HDFS，从 Python 脚本换到 Airflow DAG。\nPolars 和 DuckDB 在单机上很快，但它们就是单机。Spark 能分布式，但你得搭集群、配资源、忍受 PySpark 的序列化开销。\nDaft 的答案是：同一份代码，换一行配置就能从笔记本扩展到集群。不是\u0026quot;API 兼容\u0026quot;的那种——是字面意义上的同一份 .py 文件。这背后是一个统一的逻辑计划层：用户写的所有 DataFrame 操作先构建成一棵逻辑计划树，经过优化器处理后，交给 Runner 接口执行。NativeRunner 在本地跑 Swordfish 引擎，RayRunner 在集群上跑 Flotilla 调度器，但它们共享同一棵优化后的逻辑计划。\nHow：Daft 怎么做到的 流式执行引擎 Swordfish：Push 模型 vs Pull 模型 图：Swordfish 采用 push-based morsel-driven 模型，I/O 操作和 CPU 操作在独立的 Tokio runtime 上执行，通过异步 channel 串联形成流水线并行，配合 MemoryManager 的 permit 机制实现全局背压。\n传统引擎（包括早期的 Daft 自身）的执行模型是 Volcano 式的 pull：下游算子向上游\u0026quot;拉\u0026quot;数据，一个阶段全部处理完、物化到内存或磁盘，再拉下一个阶段的数据。这对分析查询足够了，但对 AI 管道是灾难——图片解码后体积膨胀几十倍，如果一个阶段全部物化，内存直接爆炸。\nSwordfish 选择了 push-based 的 morsel-driven 模型。\u0026ldquo;Morsel\u0026quot;是数据的最小调度单位（默认 128K 行），source 算子主动把 morsel 推给下游，下游处理完再推给更下游。算子之间用 Tokio 的异步 MPSC channel 连接，每个算子是一个独立的 async task。\n这个设计的关键在于 tokio::select! 宏——每个算子的事件循环同时监听两件事：上游 channel 是否有新数据到达，以及自己的计算任务是否完成。哪个先就绪就先处理。这让 I/O 和 CPU 的重叠不是通过额外的线程管理实现的，而是 Rust async 运行时天然提供的。\n更进一步，I/O 操作和 CPU 操作在不同的 Tokio runtime 上执行——io_runtime 专门处理网络请求和磁盘读写，compute_runtime 专门处理向量化计算和图像解码。两个线程池物理隔离，互不争抢。这意味着即使 100 个并发下载请求把 I/O 线程池打满，也不会影响 CPU 线程池上正在运行的图像解码任务。\n背压是整个流式模型的安全阀。当下游算子变慢（比如 GPU 推理成了瓶颈），它消费 channel 的速度下降，channel buffer 逐渐填满，上游算子在 send().await 上自然阻塞。但 Daft 的背压不止于此——MemoryManager 实现了一个基于 permit 的内存分配协议：每个算子在分配内存前必须申请 permit，permit 不足时异步等待。permit 用 RAII 管理，算子完成后自动释放并唤醒等待者。这意味着即使所有 channel 都没满，如果系统总内存接近上限，新任务也会被暂停。这是一种全局的、协作式的内存安全，不依赖操作系统的 OOM killer。\n这个流式模型还催生了一个自然的扩展：Dynamic Batching（动态批次）。不同操作对 batch size 的最优值截然不同——图片解码希望大 batch 来摊平 Rayon 线程池的调度开销，而 url_download 希望小 batch 来控制并发连接数。Daft 的执行引擎允许每个算子声明自己偏好的 batch size，运行时根据内存压力和操作类型自动调整，用户无需手动调参。\n一个值得一提的细节：Daft 团队在开发过程中发现，batch size 不是越大越好。v0.7.5 引入了一个回退——流式 Parquet reader 把小 batch 拼成了约 500K 行的大 batch，结果聚合查询反而慢了 2.7 倍。根因是超出了 AMD EPYC 处理器的 512KB L2 缓存。修复后保持 batch 在 L2 缓存友好的范围内，性能才恢复。这说明 Daft 的优化不只停留在\u0026quot;用 Rust 重写\u0026quot;的层面——它在 CPU 微架构级别做了对齐。\nSwordfish 的效果有多明显？Daft 团队的一个观测性博客记录了一个真实案例：把一个单体 Python UDF（内含下载、解码、裁剪、resize、推理五步操作）拆成 Daft 原生表达式后，性能从 60 秒降到 24 秒——3 倍加速，而且不是因为算法变了，纯粹是因为引擎能看见每一步并分别调度了。\nAI 感知的查询优化器：不是传统的规则引擎 Daft 的优化器是一个多阶段的规则引擎，但它和传统数据库的优化器有一个本质区别：它感知 AI 操作的异构特性。\n传统优化器处理的算子——filter、project、join、aggregate——在计算代价上是相对均匀的，都是 CPU 密集的向量化操作。但 AI 管道里的算子代价差异巨大：一个 url_download 可能需要 100ms 的网络等待，一个 image_decode 需要 1ms 的 CPU 计算，一个 LLM 推理需要 500ms 的 GPU 时间。如果把它们混在一个投影里执行，引擎没法分别控制它们的资源分配。\nDaft 的解法是一组叫 SplitUDFs 和 SplitGranularProjection 的优化规则。它们在逻辑计划层面把一个包含多种操作的投影节点拆成多个独立节点，每个节点对应一种资源特征。拆分的依据是函数的类型标记：实现了 AsyncScalarUDF trait 的函数（如 url_download）被隔离到异步执行节点，这些函数会声明自己的 preferred_batch_size（典型值是最大连接数 × 批次系数），优化器据此控制每个 stage 的并发度。\n这组规则被故意安排在优化流水线的后期——先让传统的 filter pushdown、projection pushdown 做完它们的工作，再拆分 UDF。这个顺序至关重要：如果先拆分再下推，UDF 节点会干扰 filter 的下推路径，导致过滤操作无法到达 scan 层。先下推再拆分，则确保了filter 被推到最底部 → 数据量最小化 → UDF 只在存活行上执行这个最优顺序。\n优化器还有一个针对 LLM 推理的专属优化：SplitVLLM。当检测到管道中使用了 vLLM 推理时，优化器会把推理操作提取成专门的 VLLMProject 节点，启用 Dynamic Prefix Bucketing——把共享相同 system prompt 前缀的请求分到同一 batch，复用 KV cache，使 LLM 批推理时间减半。这种优化只有在引擎层面才能做到——用户写的 UDF 看不到跨行的 prompt 结构。\n值得一提的是，Daft 在传统分析查询上也没有放松。比如 Parquet 文件读取在 v0.7.5 实现了行组级别的并行——一个大 Parquet 文件不再整体串行读取，而是把内部的行组拆开，多个行组同时读、同时解码，这一项优化就带来了 5 倍加速。Daft 的野心不只是多模态——它想在所有工作负载上都做到一流。\n全链路懒加载：从 DataFrame 到文件系统 图：Daft 的三层懒加载 —— DataFrame 层面惰性构建逻辑计划，Scan 层面在 filter/projection pushdown 后才物化为最小化扫描任务，File 层面仅在真正需要内容时才触发下载，小于 16MB 全量下载，大文件流式 range request。\nDaft 对\u0026quot;延迟执行\u0026quot;的理解比大多数框架更彻底。它不只是 DataFrame 层面的懒求值（调用 .collect() 才触发计算），而是从 API 层到文件系统层的全链路设计。\n最上层是 DataFrame 的惰性求值：所有操作只构建逻辑计划树，不触发计算。\n中间层是 Scan 的两阶段物化。Daft 的 ScanState 有两种形态：Operator（只有一个扫描算子的定义）和 Tasks（具体的扫描任务列表）。优化器的 MaterializeScans 规则被故意安排在所有 filter/projection pushdown 之后执行。这意味着：先把过滤条件推到 scan 层，先裁掉不需要的列，最后才生成最小化的具体扫描任务。如果你 100 万个文件里只有 10 万个通过了 filter，那 Daft 只会生成 10 万个扫描任务，而不是 100 万个。\n最底层是 daft.File 类型的懒 I/O。当你用 daft.from_files(\u0026quot;s3://bucket/images/\u0026quot;) 读入 100 万个文件时，Daft 不会下载任何一个文件。它创建的只是一组轻量引用。你可以用 file_path()、file_size()、guess_mime_type() 做元数据过滤——这些操作不触发真正的 I/O。其中 guess_mime_type() 的实现特别有意思：它不看文件扩展名（不可靠），而是读文件头几个字节的 magic bytes 来判断类型，只需要一次极小的 range request。\n只有当你真正需要文件内容时（比如调用 image.decode()），Daft 才会去下载。而且下载策略也是自适应的：小于 16MB 的文件全量下载到内存，大文件用 HTTP range request 流式读取，通过 BufReader 按需获取数据块。\n这三层懒加载串联起来的效果是：在大规模场景下，S3 的 egress 流量（也就是你的钱）只花在了真正需要的数据上。\n分布式架构 Flotilla：为什么不是一个 core 一个 task 图：Flotilla 每个节点运行一个 Swordfish worker，由 worker 统一管理整台机器的内存和 GPU 资源，支持动态分配和共享，而非 task-per-core 的静态切分。节点间通过 Arrow Flight RPC 零拷贝交换数据。\n当数据量超过单机极限时，Daft 可以通过 Ray 扩展到集群。但它的分布式架构（代号 Flotilla）做了一个和 Ray Data 截然不同的设计选择：每个节点只运行一个 Swordfish worker，而不是每个 core 一个 task。\n这个决策的背景是多模态数据的特性。表格数据里，每行大小基本一致——一行可能几百字节，可预测。但多模态数据完全不是：一张缩略图几 KB，一张 4K 原图几十 MB，一段视频几百 MB。在 Ray 的 task-per-core 模型下，每个 task 独立管内存，一个 task 分到几张大图就可能 OOM，同时同节点其他 task 的内存大量空闲。task 之间无法借用彼此的内存。\nFlotilla 的解法是：一个节点一个 worker，由 worker 统一管理整台机器的所有资源。Swordfish 引擎的 MemoryManager 在节点级别做全局的内存分配——不是 task 级别的。这意味着一个正在做图像解码的操作和一个正在做模型推理的操作可以共享同一台机器的内存池，按实际需求动态分配，而不是提前静态切分。\nGPU 资源也是同理。声明 gpus=0.3 意味着一块 GPU 上可以运行 3 个推理实例，由 worker 级别的调度器控制并发。这比 Ray 的\u0026quot;一个 task 锁一块 GPU\u0026quot;的模型灵活得多，尤其对那些推理计算量小但需要 GPU 的轻量模型（比如 CLIP 嵌入）。\n节点间的数据交换（shuffle）走 Apache Arrow Flight RPC 协议——基于 gRPC 的零拷贝数据传输。对于超出内存的中间数据，Flotilla 支持直接溢写到 NVMe SSD，而不是走 Ray 的 Object Store（后者需要额外的序列化开销）。\n实测结果是：在图像分类（80 万张 ImageNet）、音频转录（11 万个 Whisper 文件）、文档嵌入（1 万个 PDF）、视频目标检测（1000 个视频）四个基准测试上，Daft 比 Ray Data 快 2-7 倍，比 Spark 快 4-18 倍。更重要的是可靠性——Daft 在所有测试中零 task 失败，而 Ray Data 和 Spark 都出现了 OOM 和 task 重试。\n什么时候用 Daft，什么时候不用 适合 Daft 的场景：你的数据是多模态的（图片、音频、视频、PDF）；你的管道涉及下载、解码、嵌入生成、模型推理、去重等异构操作；你的数据在云存储上且规模不小；你需要从原型无缝扩展到生产。\n不适合的场景：小于 1GB 的纯表格分析（Polars 在单机表格查询上仍然更快）；交互式 SQL 探索（DuckDB 的嵌入式体验更好）；纯实时流处理（Flink/Kafka Streams 更合适）。\n一些真实的数字：Amazon 用 Daft 管理 EB 级 Parquet 数据，年省 4 万年 EC2 vCPU 时间；Essential AI 用 Daft 处理 24 万亿 token 的训练数据，7 天零崩溃；Together AI 的 100TB+ 文本去重管道获得了 10 倍加速；Sourcetable 用 Daft 做 AI 电子表格的核心引擎，16 个月零 bug。\n回到开头 100 万张图片的去重和分类，10 万份 PDF 的 OCR 和嵌入，24 万亿 token 的训练数据清洗——这些任务的共同点是：数据是多模态的，操作是异构的，规模是弹性的。\nDaft 做的事情，是让引擎理解这些操作：filter 下推让你只处理需要的文件，I/O 和 CPU 的运行时隔离让下载不阻塞计算，permit 级的内存背压让你不用担心 OOM，类型系统的自动提升让数据在管道中零拷贝地流动，节点级的资源管理让 GPU 不被浪费。同一份代码在笔记本上跑 1000 条调试，在集群上跑 10 亿条生产。\n这就是 Daft 的核心主张：多模态数据管道不应该比 SQL 查询更难写。\n传统数据引擎用了二十年让 SELECT ... JOIN ... GROUP BY 变得高效和易用。Daft 想为 download → decode → embed → deduplicate → write 做同样的事。工具应该适应工作负载，而不是反过来。\n","permalink":"https://luoyuxia.github.io/posts/%E5%BD%93-ai-%E9%81%87%E4%B8%8A%E6%95%B0%E6%8D%AE%E7%AE%A1%E9%81%93daft%E4%B8%80%E4%B8%AA%E5%A4%9A%E6%A8%A1%E6%80%81%E6%97%B6%E4%BB%A3%E7%9A%84%E6%95%B0%E6%8D%AE%E5%BC%95%E6%93%8E/","summary":"Daft 是一个为多模态 AI 工作负载设计的数据引擎。Python API + Rust 引擎，原生支持图片、音频、视频等非结构化数据类型，同一份代码从笔记本无缝扩展到分布式集群。本文从 What / Why / How 三个维度介绍 Daft 的设计哲学和关键技术。","title":"当 AI 遇上数据管道：Daft，一个多模态时代的数据引擎"},{"content":"背景 Mooncake Labs 团队核心成员来自 SingleStore,SingleStore（前身 MemSQL）是一家做 HTAP（Hybrid Transactional/Analytical Processing）的数据库公司，试图在一个引擎里同时搞定 OLTP 和 OLAP。\n有意思的是，从 SingleStore 出来后，Mooncake 团队选择了一条不同的路线：不做 HTAP，而是让 Postgres 专注 OLTP，通过实时同步把数据镜像到 Iceberg 列存，分析交给 DuckDB。 两个引擎各做各自擅长的事，中间用 Mooncake 做实时桥接，实现 Postgres 上的实时分析。\n2025 年 10 月，Databricks 宣布收购 Mooncake Labs，将其技术整合进 Lakebase——Databricks 正在构建的基于 Postgres 的 OLTP 数据库，面向 AI Agent 场景。收购的核心价值在于 Mooncake 的实时同步能力：Postgres 的变更实时镜像到 Lakehouse，应用、分析和 AI 共享同一份新鲜数据，不需要 ETL。\n因为之前 Mooncake 在 CMU 分享的时候 cue 到了 Fluss，说借鉴了 Fluss 和 Paimon 的 UnionRead 的思路，所以一直想找一个机会学习下 Mooncake 的原理。刚好最近开始在看 Fluss 与 Paimon/Iceberg 主键表 deletion vector 的结合，解锁主键表的极致查询，所以浅浅学习一下。\n解决的痛点 把 Postgres CDC 实时写进 Iceberg，用 Flink 就能做。但核心痛点有两个：\n删除只能靠 Equality Delete，查询越来越慢 Iceberg 支持两种删除方式：Equality Delete（按值删，记录\u0026quot;id=42 被删了\u0026quot;）和 Deletion Vector（按位置删，标记\u0026quot;第 105 行被删了\u0026quot;）。Deletion Vector 查询时只需按位过滤，效率远高于 Equality Delete。\n但 Flink 流式写入生成不了 Deletion Vector——因为数据写进 Parquet 后就\u0026quot;忘了\u0026quot;每行的位置，收到一条 DELETE id=42 时，根本不知道 id=42 在哪个文件的第几行。所以只能退回到 Equality Delete：每个 checkpoint 周期把累积的删除攒成一个 Equality Delete 文件，查询时要和数据文件做 JOIN。checkpoint 频率越高，Equality Delete 文件堆积越快，查询代价线性增长。\n实时写入的数据不可查 CDC 流到 Iceberg 的数据要等 checkpoint 提交（通常是分钟级）后才对查询可见，中间这段\u0026quot;实时数据\u0026quot;查不到，实时分析也无从谈起\n怎么解决 Mooncake 针对这两个问题给出了方案：用 Deletion Vector 替代 Equality Delete，用 Union Read 让实时数据也能被查到，整体架构如下图所示：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Postgres WAL | | CDC v +-----------+ flush +----------------+ persist +------------------+ | Arrow | -----------\u0026gt; | Parquet + DV | ---------\u0026gt; | Iceberg (S3/GCS) | | in-memory | | on NVMe | | Parquet + Puffin | +-----------+ +----------------+ +------------------+ | | | | BatchDeletionVector | Position Delete | Deletion Vector | (MemIndex) | (FileIndex) | (Puffin) | | | +----------------------------+------------------------------+ | Union Read (DuckDB) 怎么实时生成 Deletion Vector 前面说了，Flink 生成不了 Deletion Vector 是因为写完就忘了行在哪。Mooncake 的思路很直接：写入时就建索引，记住每行在哪个文件的第几行，删除时查索引拿到位置，直接生成 Deletion Vector。\n核心是一个 GlobalIndex （FileIndex）——全局哈希索引，维护 主键 → (file_id, row_offset) 的映射。每当一行数据落盘写入 Parquet 文件，就在索引里记录它的位置。索引热数据在内存，冷数据持久化到 NVMe。\n当一条 DELETE id=42 从 CDC 流过来，流程就是：\n查 GlobalIndex：id=42 → (data-file-7, 第 105 行) 在 data-file-7 对应的 Deletion Vector 中标记第 105 行已删除 Parquet 是不可变的，不能原地改，所以只能通过外挂的 Deletion Vector 来标记删除。每个 Parquet 文件对应一个 Deletion Vector（位图）。\nUPDATE 被拆成 DELETE + INSERT：先查索引在旧位置标记删除，再在新位置追加一行并更新索引。\nGlobalIndex Data 写入后的 GlobalIndex 的更新 GlobalIndex 本质是一个持久化的分桶哈希表（Bucket Hash Map），维护 主键 → (file_id, row_offset) 的映射。\n什么时候生成：每当内存中的 Arrow 数据 flush 到 Parquet 文件时，同步构建这批数据的索引。flush 过程中把每行的 (主键哈希值, 文件内序号, 行号) 收集起来， 构建一个索引块（IndexBlock）。每次 flush 产生一个新的索引块，注册到全局索引中。\n内部结构：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 主键 id=42 │ │ splitmix64 哈希 ▼ 哈希值: 0xA3B1... (64 bit) │ ├── 高 N 位 → 桶编号 (bucket index) └── 低 (64-N) 位 → 存入桶内，用于精确匹配 桶数组 (长度 = num_buckets，2 的幂次) ┌─────────┬─────────┬─────────┬─────────┐ │ bucket 0│ bucket 1│ bucket 2│ ... │ └────┬────┴─────────┴────┬────┴─────────┘ │ │ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ hash_low_bits│ │ hash_low_bits│ │ file_id │ │ file_id │ │ row_offset │ │ row_offset │ └──────────────┘ └──────────────┘ 桶的数量根据这批数据的行数动态计算：num_buckets = next_power_of_two(num_rows / 4 + 2)。比如 flush 了 1000 行，桶数就是 256（(1000/4+2) 向上取 2 的幂）。每个桶平均约 4 个条目，保证查询时桶内遍历很短。\n怎么定桶：桶数量一定是 2 的幂，所以 N = log2(桶数量)，取哈希值的高 N 位就是桶编号。比如 256 个桶，N=8，取高 8 位。位移即可，不需要取模。每个索引块在构建时把自己的 N 记录为 hash_upper_bits，查询时按这个值取高位定桶。\n每次 flush 产生一个独立的索引块，索引块之间互不影响。如果存在多个索引块，查询时需要逐个索引块查——在每个索引块内部是 O(1) 哈希定桶，但索引块多了查询次数就多。所以需要后台做索引合并：合并后只剩一个索引块，查一次就够了。\n桶内每个条目存三个字段：(哈希低位, file_id, row_offset)，全部用位压缩紧凑编码——file_id 只用 log2(文件数) 个 bit，row_offset 固定 32 bit——尽量压缩索引体积。整个索引块序列化到磁盘文件，运行时通过 mmap 映射到内存，查询时不需要额外的磁盘 I/O。\n写入、查询、合并的完整流程：\n用一个例子串起来。假设系统陆续 flush 了三批数据：\n1 2 3 第 1 次 flush: 1000 行 → parquet-1 + IndexBlock-1 (256 桶, N=8) 第 2 次 flush: 500 行 → parquet-2 + IndexBlock-2 (128 桶, N=7) 第 3 次 flush: 2000 行 → parquet-3 + IndexBlock-3 (512 桶, N=9) 每次 flush 都是独立的：产生一个新的 Parquet 文件和一个新的索引块，索引块只覆盖这一批数据，桶数量根据行数独立计算。已有的索引块不会被修改——和 Parquet 一样，写完就不可变。\n现在收到一条 DELETE id=42，查询流程：\n1 2 3 4 5 6 splitmix64(id=42) → 哈希值 0xA3B1... 查 IndexBlock-1 (N=8): 取高 8 位 → bucket 163 → 桶内遍历 → 未命中 查 IndexBlock-2 (N=7): 取高 7 位 → bucket 81 → 桶内遍历 → 未命中 查 IndexBlock-3 (N=9): 取高 9 位 → bucket 326 → 桶内遍历 → 命中！ → (parquet-3, 第 105 行) 每个索引块内部定桶是 O(1)（位移取高位），桶内平均 4 个条目，遍历很快。但索引块越多，要查的次数越多。\n索引合并就是解决这个问题：Mooncake 后台把多个小索引块归并成一个大索引块。\n1 2 3 合并前: IndexBlock-1 (256桶) + IndexBlock-2 (128桶) + IndexBlock-3 (512桶) ↓ build_from_merge：归并迭代器按哈希值有序归并 合并后: IndexBlock-merged (1024桶, N=10) 合并后所有数据在同一个索引块里，查询一次定桶就够了。这个思路和 LSM-Tree 的 compaction 一样：写入只追加新块，读取多路查找，后台合并减少读放大。\nData Compaction 后 GlobalIndex 的更新 如果只考虑写入，GlobalIndex 的更新比较简单——每次 flush 追加一个新索引块就行。\n但 Compaction 后就复杂了：多个小 Parquet 文件合并成大文件，行的物理位置全变了，索引里记录的 (file_id, row_offset) 全部失效。\nMooncake 自己的 Compaction 流程同时处理数据文件和索引：\n合并数据文件 ：读取多个小 Parquet，应用各自的 Deletion Vector 过滤掉已删除的行，把存活的行写入新的大 Parquet 文件。写入过程中记录每一行的位置映射：旧 (file_id, row_offset) → 新 (file_id, row_offset)。\n重建索引 ：拿到位置映射表后重建索引。用一个具体例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 Compaction 前： parquet-1: [row0: id=10, row1: id=20, row2: id=30(已删除)] parquet-2: [row0: id=40, row1: id=50] IndexBlock-1: id=10 → (parquet-1, row0) id=20 → (parquet-1, row1) id=30 → (parquet-1, row2) IndexBlock-2: id=40 → (parquet-2, row0) id=50 → (parquet-2, row1) ↓ 合并数据文件，应用 Deletion Vector，id=30 被过滤 Compaction 后： parquet-new: [row0: id=10, row1: id=20, row2: id=40, row3: id=50] 位置映射表： (parquet-1, row0) → (parquet-new, row0) ✓ 存活 (parquet-1, row1) → (parquet-new, row1) ✓ 存活 (parquet-1, row2) → 无映射 ✗ 已删除 (parquet-2, row0) → (parquet-new, row2) ✓ 存活 (parquet-2, row1) → (parquet-new, row3) ✓ 存活 ↓ 遍历旧索引条目，查映射表，替换位置 IndexBlock-new: id=10 → (parquet-new, row0) id=20 → (parquet-new, row1) id=30 → 跳过（映射表中不存在） id=40 → (parquet-new, row2) id=50 → (parquet-new, row3) 重建索引的详细流程：\n第一步：构建归并迭代器。 Compaction 不会重写所有文件，只选一部分小文件（或删除比例高的文件）合并。选中数据文件的同时，也会选中这些文件关联的索引块。把这些被选中的旧索引块合在一起，创建一个归并迭代器（MergingIterator），按哈希值顺序依次吐出每一个旧条目 (hash, old_file_id, old_row_offset)。没被选中的文件和索引块不受影响。\n第二步：逐条处理。 对迭代器吐出的每一个旧条目：\n用 (old_file_id, old_row_offset) 去查位置映射表 查到了 → 拿到新位置 (new_file_id, new_row_offset)，写入新索引块 查不到 → 说明这行在 Compaction 中已被 Deletion Vector 过滤，直接跳过 第三步：生成新索引块。 所有旧条目处理完后，新索引块构建完成——只包含存活行，指向 Compaction 后的新 Parquet 文件。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 旧索引块 位置映射表 新索引块 ┌──────────────┐ │ IndexBlock-1 │──┐ └──────────────┘ │ ├→ 归并迭代器 ─→ 逐条输出旧条目 ┌──────────────┐ │ │ │ IndexBlock-2 │──┘ │ └──────────────┘ ▼ ┌─────────────────────┐ 旧条目 ──→ │ 查位置映射表 │ └──────┬──────────────┘ │ ┌──────────┴──────────┐ │ │ 查到映射 未查到 (行存活) (行已删除) │ │ ▼ ▼ 替换为新位置 直接跳过 写入新索引块 │ ▼ ┌────────────────┐ │ IndexBlock-new │ 只包含存活行 │ 指向 parquet-new│ 的全新索引块 └────────────────┘ 整个过程是一次线性扫描：归并迭代器保证每个旧条目只访问一次，映射表查询是 O(1) 的哈希查找，所以重建索引的时间复杂度是 O(旧条目总数)，和数据量成正比，没有额外的放大。\n原子替换 ：新的数据文件和索引块准备好后，在原子地替换掉旧的文件和索引。旧文件随后被清理。 所以 Compaction 不只是合并数据文件，而是数据文件和索引的联动更新。这也是 Mooncake 选择自己做 Compaction 而不依赖外部 Spark 的原因之一——外部引擎没法同步更新 Mooncake 的 GlobalIndex。\n目前 Mooncake 只用自己的引擎做 Compaction，好处是数据文件和索引可以在同一个流程里联动更新，实现简洁。但代价是 Compaction 的吞吐受限于单机——数据量大了之后，靠自己做 Compaction 会成为瓶颈。\n怎么做 Union Read 前面解决了第一个痛点（用 GlobalIndex 实时生成 Deletion Vector 替代 Equality Delete）。Union Read 解决第二个痛点：让还没落盘到 Iceberg 的实时数据也能被查到。\n三个数据源 一次查询需要合并三个数据源：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 DuckDB Query | +--------------+--------------+ | | | v v v Iceberg Parquet Arrow Batches Deletions (persisted) (in-memory) | +------+------+ | | | v v v DV PD BatchDV (Puffin) (list) (in-memory) DV = Deletion Vector, persisted in Puffin PD = Position Delete, committed but not persisted BatchDV = BatchDeletionVector, in-memory batch bitmap Iceberg Parquet 文件 ：已经持久化到对象存储的数据，这是存量。\n内存中的 Arrow 批次 ：Postgres CDC 过来的数据先写到内存的 Arrow 缓冲区，还没 flush 到 Parquet。这部分是\u0026quot;实时增量\u0026quot;。\n删除信息 ：一条 DELETE 来了之后，需要先找到目标行在哪。前面讲过 FileIndex（GlobalIndex）负责定位磁盘上的行，但内存中的行它管不了。所以 Mooncake 的索引实际上是两层：\nFileIndex （前面详细讲过的 GlobalIndex）：维护 主键 → (file_id, row_offset)，定位磁盘 Parquet 文件中的行。持久化的分桶哈希表，mmap 到内存。 MemIndex ：维护 主键 → (batch_id, row_offset)，定位内存 Arrow 批次中的行。纯内存的哈希表（hashbrown::HashTable），每个 Arrow 批次对应一个 MemIndex，行写入内存时同步插入。 DELETE 来了先查 MemIndex，再查 FileIndex，找到目标行后根据位置不同产生三种删除：\nDeletion Vector （已持久化）：已经写入 Iceberg Puffin 文件的删除位图，查询时按位过滤。针对磁盘上的 Parquet 文件。 Position Delete （已提交但未持久化）：FileIndex 命中，查到目标行在磁盘文件的 (file_id, row_offset)，但还没来得及写入 Iceberg Puffin。以 (文件编号, 行号) 列表的形式传给 DuckDB。 BatchDeletionVector （内存删除）：MemIndex 命中，目标行还在内存 Arrow 批次中，直接在该批次的位图里标记删除。这种删除不需要传给 DuckDB——内存批次序列化到临时 Parquet 的过程中会应用这个位图，已删除的行直接被过滤掉，不会出现在临时文件里。 ReadState：把三个源打包成一个快照 Mooncake 在收到查询请求时，会组装一个 ReadState ，把上述三个数据源打包成一个一致性快照：\n收集 Iceberg 数据文件 ：从当前快照的 disk_files 中取出所有已持久化的 Parquet 文件路径。\n序列化内存数据 ：把内存中已提交的 Arrow 批次写到一个临时 Parquet 文件。注意只包含已提交的部分——根据 last_commit 位置截断，未提交的事务不可见。\n注意：是的，这里是将Arrow 批次写到一个临时 Parquet 文件。嗯，就是这么粗暴，直接。\n收集删除信息 ：两种删除合在一起——已持久化的 Deletion Vector（Puffin 文件引用）和未持久化的 Position Delete（(文件编号, 行号) 列表）。 最终 ReadState 包含：data_files（Iceberg 文件 + 临时内存文件）、puffin_files（Puffin 文件路径）、deletion_vectors（已持久化的删除位图引用）、position_deletes（未持久化的删除列表）。序列化后通过 Unix Socket 发给 DuckDB。\nDuckDB 怎么用 ReadState DuckDB 拿到 ReadState 后：\n打开所有 data_files（Iceberg 远程文件 + 本地临时文件），统一当作 Parquet 扫描。 对每个数据文件，合并两种删除信息：把 Deletion Vector（Roaring Bitmap）和 Position Delete 合并成一个完整的删除集合。 用删除集合构建 Access Plan ——在读 Parquet 之前就跳过已删除的行，不是读出来再过滤，而是直接不读。 这样 DuckDB 看到的就是一份完整的、包含实时数据、排除已删除行的一致视图。\n总结 Mooncake 选择的这个方向（值得一提是的 Mooncake 最早专注做数据摄入，后面才开始投入 union read 方向）是吸引人的，但更聚焦于 Postgres 生态。整体设计选择了\u0026quot;简单优先\u0026quot;，在小规模场景下跑得通，但受限于单机执行和 compaction，可以预见在大规模数据下会存在瓶颈。\n个人感想 就海外而言，Iceberg 已经成为事实标准——Snowflake、Databricks、AWS 都在积极拥抱 Iceberg。Data Infra 只有积极拥抱 Iceberg，围绕它做周边（实时写入、查询加速、索引、Compaction、CDC 同步……），才能搭上这波生态的快车。Mooncake 就是一个典型：在 Iceberg 之上补齐实时能力，最终被 Databricks 看中收购。\n所幸的是 Fluss 也在这个方向上——走\u0026quot;湖流一体\u0026quot;，用一套实时存储同时承接流式消费和湖上分析，数据实时写入 Fluss，后台自动沉降到 Paimon/Iceberg 湖存储，流和湖共享同一份数据。并且同样通过 Union Read 满足秒级新鲜度的数据查询需求。\n和 Mooncake 的思路异曲同工：都是在 湖生态上补齐实时能力，只是入口不同——Mooncake 从 Postgres CDC 切入，Fluss 从流计算切入，满足更多的数据接入。而且 Fluss 是分布式架构，天然可以支撑更大规模的数据量，不会像 Mooncake 那样受限于单机 Compaction 的瓶颈。\n","permalink":"https://luoyuxia.github.io/posts/%E6%B5%85%E6%B5%85%E5%AD%A6%E4%B9%A0%E4%B8%80%E4%B8%8B-mooncake---%E5%A6%82%E4%BD%95%E8%AE%A9-postgres-%E7%9A%84%E6%AF%8F%E4%B8%80%E6%AC%A1%E5%86%99%E5%85%A5iceberg-%E9%83%BD%E8%83%BD%E5%AE%9E%E6%97%B6%E7%9C%8B%E8%A7%81/","summary":"Mooncake通过GlobalIndex实时生成DeletionVector替代低效EqualityDelete，并结合UnionRead将内存Arrow批次、磁盘Parquet与多级删除信息统一查询，实现Postgres到Iceberg的毫秒级实时同步与分析。","title":"浅浅学习一下 Mooncake - 如何让 Postgres 的每一次写入，Iceberg 都能实时看见"},{"content":"本文通过实现四种不同的链表，逐步深入 Rust 的核心机制。每一版链表都会暴露新的问题，驱动我们学习新的语言特性：\n版本 数据结构 核心知识点 v1 不太优秀的单向链表 Box + 自定义 enum Link 内存布局、所有权转移、mem::replace v2 还可以的单向链表 Option\u0026lt;Box\u0026lt;Node\u0026gt;\u0026gt; + 泛型 take()、as_ref()、生命周期、三种迭代器 v3 持久化单向链表 Rc 共享所有权 引用计数、不可变共享、Rc::try_unwrap v4 双端队列 Rc\u0026lt;RefCell\u0026lt;Node\u0026gt;\u0026gt; 内部可变性、borrow_mut()、Ref::map 的局限 v5 unsafe 队列 裸指针 *mut Node Box::into_raw、Box::from_raw、Miri、UB v1：不太优秀的单向链表（栈） 基本布局 最直觉的实现——用枚举递归定义链表：\n1 2 3 4 pub enum List { Empty, Elem(i32, List), } 编译器直接报错：\n1 2 3 4 5 1 | pub enum List { | ^^^^^^^^^^^^^ recursive type has infinite size 3 | Elem(i32, List), | ---- recursive without indirection help: insert some indirection (e.g., a `Box`, `Rc`, or `\u0026amp;`) to make `List` representable List 是一个递归类型，编译器无法在编译期确定它的大小。为什么确定大小这么重要？因为 Rust 的函数调用依赖它——栈帧的布局（每个局部变量的偏移量、函数栈帧的总大小）在编译期就要确定，运行时不能动态调整：\n1 2 3 fn foo() { let x: List = List::Empty; // 编译器需要知道：栈帧给 x 留多少字节？ } 而编译器计算 List 大小时会陷入无限递归：\nList 的大小 = max(Empty, Elem) = Elem 的大小 Elem 的大小 = i32(4 字节) + List 的大小 List 的大小 = 4 + List 的大小 = 4 + 4 + List 的大小 = \u0026hellip; 算不出一个确定的数字，所以拒绝编译。解决办法是加一层间接引用 Box，让递归部分变成一个固定大小的堆指针：\n1 2 3 4 5 pub enum List { Empty, // 0 字节 Elem(i32, Box\u0026lt;List\u0026gt;), // 4 字节 + 8 字节(指针) = 12 字节（再加对齐） } // List 大小 = max(0, 12) = 确定了！ 能编译了，但内存布局有两个问题：\n问题 1：空节点浪费空间。由于 enum 的内存对齐，Empty 变体也要占用和 Elem 一样大的空间（至少要存下最大变体的大小）。\n问题 2：首节点在栈上，其余在堆上。这导致分割/合并链表时需要在栈和堆之间搬运数据：\n1 2 3 4 5 6 布局（当前）： [Elem A, ptr] -\u0026gt; (Elem B, ptr) -\u0026gt; (Elem C, ptr) -\u0026gt; (Empty *junk*) 分割 C 后： [Elem A, ptr] -\u0026gt; (Elem B, ptr) -\u0026gt; (Empty *junk*) [Elem C, ptr] -\u0026gt; (Empty *junk*) ← C 从堆上搬到了栈上，产生额外拷贝 更好的布局是：所有节点都在堆上，链表本身只持有一个指针，空尾用 null 表示而非一个 Empty 节点：\n1 2 3 4 5 6 布局（理想）： [ptr] -\u0026gt; (Elem A, ptr) -\u0026gt; (Elem B, ptr) -\u0026gt; (Elem C, *null*) 分割 C 后： [ptr] -\u0026gt; (Elem A, ptr) -\u0026gt; (Elem B, *null*) [ptr] -\u0026gt; (Elem C, *null*) ← 只需改指针，无需拷贝 如下图所示：\n为此，我们将链表拆分为三个类型：\n1 2 3 4 5 6 7 8 9 10 11 12 13 pub struct List { head: Link, } enum Link { Empty, More(Box\u0026lt;Node\u0026gt;), } struct Node { elem: i32, next: Link, } List 只是一个指针大小的栈上结构，所有 Node 都通过 Box 分配在堆上，空尾用 Link::Empty 表示（不占额外空间，因为编译器会将 Box 的 null 指针优化为 Empty 变体）。\nPush 创建一个新节点，让它的 next 指向当前 head，然后更新 head 指向新节点：\n1 2 3 4 5 6 7 8 9 impl List { pub fn push(\u0026amp;mut self, elem: i32) { let new_node = Node { elem: elem, next: self.head, }; self.head = Link::More(Box::new(new_node)); } } 编译报错：\n1 2 3 4 5 6 error[E0507]: cannot move out of `self.head` which is behind a mutable reference --\u0026gt; src/first.rs:23:19 | 23 | next: self.head, | ^^^^^^^^^ move occurs because `self.head` has type `Link`, | which does not implement the `C self 是一个 \u0026amp;mut 借用，我们试图将 self.head 的所有权移动给 next，这会让 self 处于一个不完整的状态（head 被拿走了但还没放回新值），Rust 不允许这样做\n不太优的方案：clone\n1 2 3 4 5 6 7 8 9 10 11 12 13 #[derive(Clone)] enum Link { ... } #[derive(Clone)] struct Node { ... } pub fn push(\u0026amp;mut self, elem: i32) { let new_node = Node { elem: elem, next: self.head.clone(), // 深拷贝整条链表，O(n) 开销 }; self.head = Link::More(Box::new(new_node)); } 能编译，但 clone() 会深拷贝从 head 开始的整条链表，完全不可接受。\n更优的方案：mem::replace\n我们需要的是：从 self.head 中\u0026quot;偷\u0026quot;出值，同时放入一个临时占位值，保证 self 始终处于合法状态：\nmem::replace 原理如下图所示：\n1 2 3 4 5 6 7 8 pub fn push(\u0026amp;mut self, elem: i32) { let new_node = Box::new(Node { elem: elem, next: std::mem::replace(\u0026amp;mut self.head, Link::Empty), }); self.head = Link::More(new_node); } mem::replace(\u0026amp;mut self.head, Link::Empty) 做了两件事：把 self.head 的值取出来返回，同时把 Link::Empty 放进去。这样 self 在整个过程中始终是完整的。\nPop 1 2 3 4 5 6 7 8 9 pub fn pop(\u0026amp;mut self) -\u0026gt; Option\u0026lt;i32\u0026gt; { match std::mem::replace(\u0026amp;mut self.head, Link::Empty) { Link::Empty =\u0026gt; None, Link::More(node) =\u0026gt; { self.head = node.next; Some(node.elem) } } } 同样使用 mem::replace 先把 head 偷出来，再根据情况处理。\nv2：还可以的单向链表 v1 有两个不优雅的地方：\n每次操作都要用 mem::replace，过于 hack\n额外定义了一个 Link 枚举，其实 Rust 内置的 Option 完全能胜任\n用 Option 替代 Link 1 2 3 4 5 6 7 8 9 10 11 12 pub struct List\u0026lt;T\u0026gt; { head: Link\u0026lt;T\u0026gt;, } type Link\u0026lt;T\u0026gt; = Option\u0026lt;Box\u0026lt;Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;; // 等价于之前的： // enum Link { Empty, More(Box\u0026lt;Node\u0026gt;) } struct Node\u0026lt;T\u0026gt; { elem: T, next: Link\u0026lt;T\u0026gt;, } Option 自带 take() 方法，功能等同于 mem::replace(\u0026amp;mut self.head, None)，代码立刻清爽了：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn new() -\u0026gt; Self { List { head: None } } pub fn push(\u0026amp;mut self, elem: T) { let new_node = Box::new(Node { elem: elem, next: self.head.take(), // 取出 head，原位留下 None }); self.head = Some(new_node); } pub fn pop(\u0026amp;mut self) -\u0026gt; Option\u0026lt;T\u0026gt; { self.head.take().map(|node| { self.head = node.next; node.elem }) } } Peek 返回链表表头元素的引用：\n1 2 3 4 5 pub fn peek(\u0026amp;self) -\u0026gt; Option\u0026lt;\u0026amp;T\u0026gt; { self.head.map(|node| { \u0026amp;node.elem }) } 编译报错：\n1 2 error[E0515]: cannot return reference to local data `node.elem` error[E0507]: cannot move out of borrowed content 问题在于 map 会拿走 self.head 的所有权（Option\u0026lt;T\u0026gt; 的 map 消费 self），闭包内的 node 是一个局部变量，函数返回后就会被销毁，我们不能返回它的引用。\n解决办法：先用 as_ref() 将 Option\u0026lt;Box\u0026lt;Node\u0026gt;\u0026gt; 转换成 Option\u0026lt;\u0026amp;Box\u0026lt;Node\u0026gt;\u0026gt;，这样 map 操作的就是引用而非所有权：\n1 2 3 4 5 6 7 8 9 10 11 pub fn peek(\u0026amp;self) -\u0026gt; Option\u0026lt;\u0026amp;T\u0026gt; { self.head.as_ref().map(|node| { \u0026amp;node.elem }) } pub fn peek_mut(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;mut T\u0026gt; { self.head.as_mut().map(|node| { \u0026amp;mut node.elem }) } as_ref() / as_mut() 是 Option 编程中极其常用的方法：\n1 2 3 4 impl\u0026lt;T\u0026gt; Option\u0026lt;T\u0026gt; { pub fn as_ref(\u0026amp;self) -\u0026gt; Option\u0026lt;\u0026amp;T\u0026gt;; // Option\u0026lt;T\u0026gt; → Option\u0026lt;\u0026amp;T\u0026gt; pub fn as_mut(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;mut T\u0026gt;; // Option\u0026lt;T\u0026gt; → Option\u0026lt;\u0026amp;mut T\u0026gt; } 迭代器 集合类型应该实现 3 种迭代器：\n迭代器 产出类型 语义 IntoIter T 消费集合，转移所有权 Iter \u0026amp;T 不可变借用遍历 IterMut \u0026amp;mut T 可变借用遍历 它们都实现同一个 trait：\n1 2 3 4 pub trait Iterator { type Item; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt;; } IntoIter 最简单，直接消费链表，每次 next 就是一次 pop：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 pub struct IntoIter\u0026lt;T\u0026gt;(List\u0026lt;T\u0026gt;); impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn into_iter(self) -\u0026gt; IntoIter\u0026lt;T\u0026gt; { IntoIter(self) } } impl\u0026lt;T\u0026gt; Iterator for IntoIter\u0026lt;T\u0026gt; { type Item = T; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt; { self.0.pop() } } 不涉及引用，不涉及生命周期，最省心。\nIter 持有一个指向当前节点的引用，每次 next 返回当前元素的引用并前进到下一个节点：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 pub struct Iter\u0026lt;\u0026#39;a, T\u0026gt; { next: Option\u0026lt;\u0026amp;\u0026#39;a Node\u0026lt;T\u0026gt;\u0026gt;, } impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn iter(\u0026amp;self) -\u0026gt; Iter\u0026lt;T\u0026gt; { Iter { next: self.head.as_deref() } } } impl\u0026lt;\u0026#39;a, T\u0026gt; Iterator for Iter\u0026lt;\u0026#39;a, T\u0026gt; { type Item = \u0026amp;\u0026#39;a T; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt; { self.next.map(|node| { self.next = node.next.as_deref(); \u0026amp;node.elem }) } } 这里有几个生命周期的要点：\nIter\u0026lt;'a, T\u0026gt; 需要声明生命周期 'a，因为它内部持有引用 Iterator 的 impl 也要带 'a，因为关联类型 Item = \u0026amp;'a T 需要它 iter(\u0026amp;self) 方法不需要显式标注生命周期（生命周期消除规则自动推导） as_deref() 将 Option\u0026lt;\u0026amp;Box\u0026lt;Node\u0026gt;\u0026gt; 转换成 Option\u0026lt;\u0026amp;Node\u0026gt;，穿透 Box 直接拿到内部引用 map 在这里能正常工作，是因为 Option\u0026lt;\u0026amp;T\u0026gt;（不可变引用）实现了 Copy，map 拿到的只是引用的副本，不会转移所有权。\nIterMut 照着 Iter 改成可变版本：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 pub struct IterMut\u0026lt;\u0026#39;a, T\u0026gt; { next: Option\u0026lt;\u0026amp;\u0026#39;a mut Node\u0026lt;T\u0026gt;\u0026gt;, } impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn iter_mut(\u0026amp;mut self) -\u0026gt; IterMut\u0026lt;\u0026#39;_, T\u0026gt; { IterMut { next: self.head.as_deref_mut() } } } impl\u0026lt;\u0026#39;a, T\u0026gt; Iterator for IterMut\u0026lt;\u0026#39;a, T\u0026gt; { type Item = \u0026amp;\u0026#39;a mut T; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt; { self.next.take().map(|node| { self.next = node.next.as_deref_mut(); \u0026amp;mut node.elem }) } } 注意这里用了 self.next.take() 而不是 self.next.map(...)。原因在于 \u0026amp;mut T（可变引用）不可 Copy——同一时刻只能有一个可变引用存在。如果用 map，它会试图移动 self.next 的值，但 self 是借用的，不允许移动。take() 通过\u0026quot;取出并留下 None\u0026quot;来获得所有权，完美解决。\nv3：持久化单向链表 前面的链表都是单所有权的。在实际使用中，共享所有权更常见：\n1 2 3 4 5 6 7 list1 -\u0026gt; A ---+ | v list2 ------\u0026gt; B -\u0026gt; C -\u0026gt; D ^ | list3 -\u0026gt; X ---+ 节点 B 被三个链表共享，这带来两个问题：\nBox 是独占所有权的，无法让多个链表指向同一个节点\n当 list2 被 drop 时，B 可能还被 list1 和 list3 引用，不能直接释放\n解决方案：Rc（Reference Count 引用计数）。Rc 允许多个所有者共享同一份数据，当最后一个 Rc 被 drop 时数据才会被释放。不过 Rc 的数据是不可变的（如果需要可变，可以配合 RefCell）。\n数据布局 1 2 3 4 5 6 7 8 9 10 11 12 use std::rc::Rc; pub struct List\u0026lt;T\u0026gt; { head: Link\u0026lt;T\u0026gt;, } type Link\u0026lt;T\u0026gt; = Option\u0026lt;Rc\u0026lt;Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;; struct Node\u0026lt;T\u0026gt; { elem: T, next: Link\u0026lt;T\u0026gt;, } 基本操作 链表是不可变的，所以没有 push/pop。取而代之的是返回新链表的 prepend 和 tail：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 在头部追加元素，返回新链表（原链表不受影响） pub fn prepend(\u0026amp;self, elem: T) -\u0026gt; List\u0026lt;T\u0026gt; { List { head: Some(Rc::new(Node { elem: elem, next: self.head.clone(), // Rc 的 clone 只增加引用计数，O(1) })), } } // 返回去掉头节点的新链表 pub fn tail(\u0026amp;self) -\u0026gt; List\u0026lt;T\u0026gt; { List { head: self.head.as_ref().and_then(|node| node.next.clone()), } } // 返回头节点元素的引用 pub fn head(\u0026amp;self) -\u0026gt; Option\u0026lt;\u0026amp;T\u0026gt; { self.head.as_ref().map(|node| \u0026amp;node.elem) } 自定义 Drop 链表很长时，默认的递归 drop 可能栈溢出。手动实现迭代式 drop：\n1 2 3 4 5 6 7 8 9 10 11 12 impl\u0026lt;T\u0026gt; Drop for List\u0026lt;T\u0026gt; { fn drop(\u0026amp;mut self) { let mut head = self.head.take(); while let Some(node) = head { if let Ok(mut node) = Rc::try_unwrap(node) { head = node.next.take(); } else { break; // 还有其他引用，不能释放，停止 } } } } Rc::try_unwrap 检查当前 Rc 是否只剩一个强引用：是则返回内部值（可以安全释放），否则返回错误（说明还有其他链表在共享这个节点）。\nv4：不太优秀的双端队列 双向链表意味着每个节点同时指向前一个和后一个节点。节点被多方持有（前驱、后继、链表头尾），需要共享所有权；同时还要能修改 prev/next 指针，需要内部可变性。这就是 Rc\u0026lt;RefCell\u0026lt;Node\u0026gt;\u0026gt; 的经典应用场景。\n双端队列结构：\n数据布局 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 use std::rc::Rc; use std::cell::RefCell; pub struct List\u0026lt;T\u0026gt; { head: Link\u0026lt;T\u0026gt;, tail: Link\u0026lt;T\u0026gt;, } type Link\u0026lt;T\u0026gt; = Option\u0026lt;Rc\u0026lt;RefCell\u0026lt;Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;\u0026gt;; struct Node\u0026lt;T\u0026gt; { elem: T, next: Link\u0026lt;T\u0026gt;, prev: Link\u0026lt;T\u0026gt;, } 基本操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 impl\u0026lt;T\u0026gt; Node\u0026lt;T\u0026gt; { fn new(elem: T) -\u0026gt; Rc\u0026lt;RefCell\u0026lt;Self\u0026gt;\u0026gt; { Rc::new(RefCell::new(Node { elem: elem, prev: None, next: None, })) } } impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn new() -\u0026gt; Self { List { head: None, tail: None } } } push_front 1 2 3 4 5 6 7 8 9 10 11 12 13 14 pub fn push_front(\u0026amp;mut self, elem: T) { let new_head = Node::new(elem); match self.head.take() { Some(old_head) =\u0026gt; { old_head.borrow_mut().prev = Some(new_head.clone()); new_head.borrow_mut().next = Some(old_head); self.head = Some(new_head); } None =\u0026gt; { self.tail = Some(new_head.clone()); self.head = Some(new_head); } } } pop_front 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pub fn pop_front(\u0026amp;mut self) -\u0026gt; Option\u0026lt;T\u0026gt; { self.head.take().map(|old_head| { match old_head.borrow_mut().next.take() { Some(new_head) =\u0026gt; { new_head.borrow_mut().prev.take(); self.head = Some(new_head); } None =\u0026gt; { self.tail.take(); } } // Rc::try_unwrap 确认只剩一个引用后取出内部值 // .into_inner() 消费 RefCell，取出 Node Rc::try_unwrap(old_head).ok().unwrap().into_inner().elem }) } 这里不能直接 old_head.into_inner()，因为 Rc\u0026lt;T\u0026gt; 只提供不可变引用，不允许直接消费内部值。必须先用 Rc::try_unwrap 确认只剩一个强引用，拿到 RefCell\u0026lt;Node\u0026gt;，再用 into_inner() 取出 Node。\n迭代器 IntoIter 消费式迭代器比较简单，正向用 pop_front，反向用 pop_back：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 pub struct IntoIter\u0026lt;T\u0026gt;(List\u0026lt;T\u0026gt;); impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn into_iter(self) -\u0026gt; IntoIter\u0026lt;T\u0026gt; { IntoIter(self) } } impl\u0026lt;T\u0026gt; Iterator for IntoIter\u0026lt;T\u0026gt; { type Item = T; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;T\u0026gt; { self.0.pop_front() } } impl\u0026lt;T\u0026gt; DoubleEndedIterator for IntoIter\u0026lt;T\u0026gt; { fn next_back(\u0026amp;mut self) -\u0026gt; Option\u0026lt;T\u0026gt; { self.0.pop_back() } } Iter：此路不通 尝试实现借用迭代器：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 pub struct Iter\u0026lt;\u0026#39;a, T\u0026gt;(Option\u0026lt;Ref\u0026lt;\u0026#39;a, Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;); impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn iter(\u0026amp;self) -\u0026gt; Iter\u0026lt;T\u0026gt; { Iter(self.head.as_ref().map(|head| head.borrow())) } } impl\u0026lt;\u0026#39;a, T\u0026gt; Iterator for Iter\u0026lt;\u0026#39;a, T\u0026gt; { type Item = Ref\u0026lt;\u0026#39;a, T\u0026gt;; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt; { self.0.take().map(|node_ref| { self.0 = node_ref.next.as_ref().map(|head| head.borrow()); Ref::map(node_ref, |node| \u0026amp;node.elem) }) } } 编译报错：\n1 2 3 4 5 6 error[E0521]: borrowed data escapes outside of closure | 155 | self.0 = node_ref.next.as_ref().map(|head| head.borrow()); | ^^^^^^ -------- borrow is only valid in the closure body | | | reference to `node_ref` escapes the closure body here 问题在于 RefCell::borrow() 返回的 Ref 的生命周期与 RefCell 绑定，而不是与数据本身绑定。我们需要在使用 node_ref（当前节点的 Ref）的同时，从它内部借用下一个节点——这要求 node_ref 活得比闭包更久，但它的所有权马上就要被 Ref::map 消费掉。\n尝试用 Ref::map_split 拆分也无济于事，根本矛盾是：Ref 不允许被拆分成跨越多个 RefCell 的引用。\nRc\u0026lt;RefCell\u0026gt; 双向链表的借用迭代器在安全 Rust 中基本无法实现。这是内部可变性的固有局限——RefCell 的运行时借用检查无法像编译期借用检查那样灵活地拆分引用。\nv5：不错的 unsafe 队列 Rc\u0026lt;RefCell\u0026gt; 的方案过于复杂且有诸多限制。对于需要可变性的链表场景，裸指针 + unsafe 反而是更务实的选择。\nUnsafe 裸指针内存管理:\n第一版：混用 Box 和裸指针 1 2 3 4 5 6 7 8 9 10 11 pub struct List\u0026lt;T\u0026gt; { head: Link\u0026lt;T\u0026gt;, tail: *mut Node\u0026lt;T\u0026gt;, // 裸指针，指向尾节点，方便尾部追加 } type Link\u0026lt;T\u0026gt; = Option\u0026lt;Box\u0026lt;Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;; struct Node\u0026lt;T\u0026gt; { elem: T, next: Link\u0026lt;T\u0026gt;, } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 pub fn push(\u0026amp;mut self, elem: T) { let mut new_tail = Box::new(Node { elem: elem, next: None, }); let raw_tail: *mut _ = \u0026amp;mut *new_tail; if !self.tail.is_null() { unsafe { (*self.tail).next = Some(new_tail); } } else { self.head = Some(new_tail); } self.tail = raw_tail; } pub fn pop(\u0026amp;mut self) -\u0026gt; Option\u0026lt;T\u0026gt; { self.head.take().map(|head| { let head = *head; self.head = head.next; if self.head.is_none() { self.tail = ptr::null_mut(); } head.elem }) } 能跑，但混用 Box 和裸指针会导致未定义行为（UB：Undefined Behavior）。原因在于 Rust 的别名模型（Stacked Borrows）：Box\u0026lt;T\u0026gt; 声称自己是数据的唯一所有者，但我们同时通过裸指针修改同一块内存，破坏了这个契约。\n1 2 3 4 5 6 7 8 9 10 // 类似这样的问题： unsafe { let mut data = Box::new(10); let ptr1 = (\u0026amp;mut *data) as *mut i32; *data += 10; *ptr1 += 1; // Miri 报错：UB！ println!(\u0026#34;{}\u0026#34;, data); } Rust 认为 Box 是数据的唯一修改者。但裸指针也在修改同一块内存，编译器的优化（例如基于独占引用的缓存优化）可能导致非预期行为。\n原则：一旦开始使用裸指针，就应该全程使用裸指针，避免与 Box 的所有权模型冲突。\n第二版：纯裸指针 1 2 3 4 5 6 7 8 9 10 11 pub struct List\u0026lt;T\u0026gt; { head: Link\u0026lt;T\u0026gt;, tail: *mut Node\u0026lt;T\u0026gt;, } type Link\u0026lt;T\u0026gt; = *mut Node\u0026lt;T\u0026gt;; // 全部用裸指针 struct Node\u0026lt;T\u0026gt; { elem: T, next: Link\u0026lt;T\u0026gt;, } Push 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pub fn push(\u0026amp;mut self, elem: T) { unsafe { let new_tail = Box::into_raw(Box::new(Node { elem: elem, next: ptr::null_mut(), })); if !self.tail.is_null() { (*self.tail).next = new_tail; } else { self.head = new_tail; } self.tail = new_tail; } } Box::into_raw 将 Box 转换为裸指针并放弃所有权（不会自动释放内存）。从这一刻起，内存管理完全由我们手动负责。\nPop 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pub fn pop(\u0026amp;mut self) -\u0026gt; Option\u0026lt;T\u0026gt; { unsafe { if self.head.is_null() { None } else { let head = Box::from_raw(self.head); // 重新接管所有权 self.head = head.next; if self.head.is_null() { self.tail = ptr::null_mut(); } Some(head.elem) } } } Box::from_raw 将裸指针重新包装为 Box，恢复自动内存管理。当 head 离开作用域时，Box 会自动释放内存。\nPeek 1 2 3 4 5 6 7 8 9 10 11 pub fn peek(\u0026amp;self) -\u0026gt; Option\u0026lt;\u0026amp;T\u0026gt; { unsafe { self.head.as_ref().map(|node| \u0026amp;node.elem) } } pub fn peek_mut(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;mut T\u0026gt; { unsafe { self.head.as_mut().map(|node| \u0026amp;mut node.elem) } } 裸指针的 as_ref() / as_mut() 将 *mut T 转换为 Option\u0026lt;\u0026amp;T\u0026gt; / Option\u0026lt;\u0026amp;mut T\u0026gt;，null 指针会变成 None。\n迭代器 三种迭代器的实现与 v2 的安全版本结构一致，只是内部用裸指针操作：\n1 2 3 4 5 6 7 8 9 pub struct IntoIter\u0026lt;T\u0026gt;(List\u0026lt;T\u0026gt;); pub struct Iter\u0026lt;\u0026#39;a, T\u0026gt; { next: Option\u0026lt;\u0026amp;\u0026#39;a Node\u0026lt;T\u0026gt;\u0026gt;, } pub struct IterMut\u0026lt;\u0026#39;a, T\u0026gt; { next: Option\u0026lt;\u0026amp;\u0026#39;a mut Node\u0026lt;T\u0026gt;\u0026gt;, } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 impl\u0026lt;T\u0026gt; List\u0026lt;T\u0026gt; { pub fn into_iter(self) -\u0026gt; IntoIter\u0026lt;T\u0026gt; { IntoIter(self) } pub fn iter(\u0026amp;self) -\u0026gt; Iter\u0026lt;\u0026#39;_, T\u0026gt; { unsafe { Iter { next: self.head.as_ref() } } } pub fn iter_mut(\u0026amp;mut self) -\u0026gt; IterMut\u0026lt;\u0026#39;_, T\u0026gt; { unsafe { IterMut { next: self.head.as_mut() } } } } impl\u0026lt;T\u0026gt; Iterator for IntoIter\u0026lt;T\u0026gt; { type Item = T; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt; { self.0.pop() } } impl\u0026lt;\u0026#39;a, T\u0026gt; Iterator for Iter\u0026lt;\u0026#39;a, T\u0026gt; { type Item = \u0026amp;\u0026#39;a T; fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;Self::Item\u0026gt; { unsafe { self.next.map(|node| { self.next = node.next.as_ref(); \u0026amp;node.elem }) } } } impl\u0026lt;\u0026#39;a, T\u0026gt; Iterator for IterMut\u0026lt;\u0026#39;a, T\u0026gt; { type Item = \u0026amp;\u0026#39;a mut T; 总结：用 c 的方式写 rust 真爽\n总结 通过五个版本的链表实现，我们逐步触碰了 Rust 的核心机制：\n遇到的问题 学到的知识 递归类型无限大 Box 堆分配，间接引用 不能从 \u0026amp;mut self 中移走值 mem::replace、Option::take 返回内部引用时所有权冲突 as_ref() / as_deref()，引用而非所有权 不可变引用需要标注存活范围 生命周期 'a、生命周期消除规则 \u0026amp;mut T 不可 Copy take() 获取所有权 vs map 拷贝引用 多个所有者共享数据 Rc 引用计数 共享的同时需要修改 RefCell 内部可变性 RefCell 的 Ref 无法跨节点拆分 安全 Rust 的固有局限 Box 和裸指针混用导致 UB Stacked Borrows 别名模型、Miri 检测 需要完全手动管理内存 Box::into_raw / Box::from_raw、裸指针 一句话总结：链表是 Rust 所有权系统的天然对手。正是因为链表的节点互相引用、共享、可变，才把 Rust 的每一道安全防线都触发了一遍。理解了链表，就理解了 Rust 为何如此设计。\n","permalink":"https://luoyuxia.github.io/posts/rust%E7%94%A8%E9%93%BE%E8%A1%A8%E7%90%86%E8%A7%A3%E6%89%80%E6%9C%89%E6%9D%83%E5%80%9F%E7%94%A8%E4%B8%8E-unsafe/","summary":"本文通过五版链表实现，系统演示Rust所有权、借用、生命周期、Rc/RefCell共享与内部可变性，以及unsafe裸指针等核心机制的演进与权衡。","title":"Rust：用链表理解所有权、借用与 unsafe"},{"content":" 很久没更新 Rust 相关的文章了，上次更新是 2025-09-27。随着 AI Vide Coding 的爆发，真没啥动力继续更新 Rust 相关的文章了。\n不过最近，根据周围人的亲身 Coding 经历，我逐渐意识到可能 Rust 是 Vide Coding 时代最有性价比的语言了。Rust 极陡峭的学习曲线对 AI 来说不值一提，AI 非常擅长处理那些繁琐的生命周期标注、所有权规则和类型体操。Rust的严格编译器对 AI 来说是巨大的优势——编译器就是最好的 AI 校验器。并且最重要的是，Rust 的性能是免费的。AI 写 Python 和写 Rust 工作量几乎没差别，但产出性能可能差一个数量级。\n所以朝花夕拾，有始有终，硬着头皮继续更新相关文章吧。\n异步（Async）编程介绍 异步编程允许我们同时并发运行大量的任务，却仅仅需要几个甚至一个 OS 线程或 CPU 核心。\nasync 和多线程都可以实现并发编程，后者甚至还能通过线程池来增强并发能力，但是这两个方式并不互通，从一个方式切换成另一个需要大量的代码重构工作，因此提前为自己的项目选择适合的并发模型就变得至关重要。\nOS 线程非常适合少量任务并发，因为线程的创建和上下文切换是非常昂贵的，甚至于空闲的线程都会消耗系统资源。\n对于长时间运行的 CPU 密集型任务，例如并行计算，使用线程将更有优势。这种密集任务往往会让所在的线程持续运行，任何不必要的线程切换都会带来性能损耗，因此高并发反而在此时成为了一种多余。\n而高并发更适合 IO 密集型任务，例如 web 服务器、数据库连接等网络服务，因为这些任务绝大部分时间都处于等待状态，如果使用多线程，那线程大量时间会处于无所事事的状态，再加上线程上下文切换的高昂代价，让多线程做 IO 密集任务变成了一件非常奢侈的事。\n而使用 async，既可以有效的降低 CPU 和内存的负担，又可以让大量的任务并发的运行，一个任务一旦处于 IO 或者其他等待（阻塞）状态，就会被立刻切走并执行另一个任务，而这里的任务切换的性能开销要远远低于使用多线程时的线程上下文切换。\n事实上，async 底层也是基于线程实现，但是它基于线程封装了一个运行时，可以将多个任务映射到少量线程上，然后将线程切换变成了任务切换，后者仅仅是内存中的访问，因此要高效的多。\n总之，async 编程并没有比多线程更好，最终还是根据你的使用场景作出合适的选择，如果无需高并发，或者也不在意线程切换带来的性能损耗，那么多线程使用起来会简单、方便的多！最后再简单总结下：\n有大量 IO 任务需要并发运行时，选 async 模型\n有部分 IO 任务需要并发运行时，选多线程，如果想要降低线程创建和销毁的开销，可以使用线程池\n有大量 CPU 密集任务需要并行运行时，例如并行计算，选多线程模型，且让线程数等于或者稍大于 CPU 核心数\n无所谓时，统一选多线程\nasync 和多线程的性能对比 操作 async 线程 创建 0.3 微秒 17 微秒 线程切换 0.2 微秒 1.7 微秒 异步（Async）编程基础 1 2 3 4 5 6 7 8 9 10 11 12 // `block_on`会阻塞当前线程直到指定的`Future`执行完成，这种阻塞当前线程以等待任务完成的方式较为简单、粗暴， // 好在其它运行时的执行器(executor)会提供更加复杂的行为，例如将多个`future`调度到同一个线程上执行。 use futures::executor::block_on; async fn hello_world() { println!(\u0026#34;hello, world!\u0026#34;); } fn main() { let future = hello_world(); // 返回一个Future, 因此不会打印任何输出 block_on(future); // 执行`Future`并等待其运行完成，此时\u0026#34;hello, world!\u0026#34;会被打印输出 } 这段代码展示了异步编程最基本的模式：async fn 调用后并不会立即执行，而是返回一个 Future。Future 本身只是一个\u0026quot;待执行的计划\u0026quot;，必须交给执行器才能真正运行。这里的 block_on 就是最简单的执行器——它会阻塞当前线程，不断驱动 Future 直到完成\n在 async fn 中调用另一个 async fn 如果你要在一个 async fn 函数中去调用另一个 async fn 并等待其完成后再执行后续的代码，该如何做？例如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 use futures::executor::block_on; async fn hello_world() { hello_cat(); // 将不会执行 println!(\u0026#34;hello, world!\u0026#34;); } async fn hello_cat() { println!(\u0026#34;hello, kitty!\u0026#34;); } fn main() { let future = hello_world(); block_on(future); } 输出：\n1 2 3 4 5 6 7 8 warning: unused implementer of `futures::Future` that must be used --\u0026gt; src/main.rs:6:5 | 6 | hello_cat(); | ^^^^^^^^^^^^ = note: futures do nothing unless you `.await` or poll them ... hello, world! 编译器的警告信息说得很清楚：futures do nothing unless you .await or poll them。直接调用 hello_cat() 只是创建了一个 Future 并立刻丢弃，函数体根本没有执行。要让它真正运行，需要使用 .await 来驱动这个 Future 并等待其完成。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 use futures::executor::block_on; async fn hello_world() { hello_cat().await; println!(\u0026#34;hello, world!\u0026#34;); } async fn hello_cat() { println!(\u0026#34;hello, kitty!\u0026#34;); } fn main() { let future = hello_world(); block_on(future); } 并发执行两个 Future 前面的 .await 是串行的——必须等上一个 Future 完成才能执行下一个。但很多时候我们希望多个任务同时推进，例如一边下载数据一边渲染 UI。futures::join! 宏可以做到这一点：它接收多个 Future，在同一个线程上交替 poll 它们，哪个能推进就推进哪个，从而实现并发。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 async fn async_main() { let f1 = learn_and_sing(); let f2 = dance(); // `join!`可以并发的处理和等待多个`Future`，若`learn_and_sing Future`被阻塞， // 那`dance Future`可以拿过线程的所有权继续执行。若`dance`也变成阻塞状态， // 那`learn_and_sing`又可以再次拿回线程所有权，继续执行。 // 若两个都被阻塞，那么`async main`会变成阻塞状态，然后让出线程所有权， // 并将其交给`main`函数中的`block_on`执行器 futures::join!(f1, f2); } fn main() { block_on(async_main()); } 异步原理：从零构建运行时 前面我们学会了 async/await 的基本用法，接下来深入底层，理解 Rust 异步运行时的核心机制。本章的脉络如下：\n先用一个简化版 Future 建立直觉（SimpleFuture）\n过渡到 Rust 标准库中真实的 Future（Pin、Context）\n亲手实现一个自定义 Future，并看看编译器如何将 async fn 变成状态机\n从零构建执行器，经历V1 忙轮询 → V2 按需唤醒的演进，理解 Waker 的意义\n最后把所有组件串起来，看完整的运行流程\nFuture 特征（简化版） Future 的定义：它是一个能产出值的异步计算。我们先用一个简化版的 trait 来理解核心思想：\n1 2 3 4 5 6 7 8 9 trait SimpleFuture { type Output; fn poll(\u0026amp;mut self, wake: fn()) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt;; } enum Poll\u0026lt;T\u0026gt; { Ready(T), Pending, } Future 需要被执行器 poll（轮询）后才能运行，通过调用该方法，可以推进 Future 的进一步执行，直到被切走为止。\n在当前 poll 中，Future 可以被完成，则会返回 Poll::Ready(result)，反之则返回 Poll::Pending，并且安排一个 wake 函数：当未来 Future 准备好进一步执行时，该函数会被调用，然后管理该 Future 的执行器会再次调用 poll 方法，此时 Future 就可以继续执行了。\n考虑一个需要从 socket 读取数据的场景：如果有数据，可以直接读取数据并返回 Poll::Ready(data)，但如果没有数据，Future 会被阻塞且不会再继续执行，此时它会注册一个 wake 函数，当 socket 数据准备好时，该函数将被调用以通知执行器：我们的 Future 已经准备好了，可以继续执行。\n下面的 SocketRead 结构体就是一个 Future：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 socket: \u0026amp;\u0026#39;a Socket, } impl SimpleFuture for SocketRead\u0026lt;\u0026#39;_\u0026gt; { type Output = Vec\u0026lt;u8\u0026gt;; fn poll(\u0026amp;mut self, wake: fn()) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt; { if self.socket.has_data_to_read() { // socket有数据，写入buffer中并返回 Poll::Ready(self.socket.read_buf()) } else { // socket中还没数据 // // 注册一个`wake`函数，当数据可用时，该函数会被调用， // 然后当前Future的执行器会再次调用`poll`方法，此时就可以读取到数据 self.socket.set_readable_callback(wake); Poll::Pending } } } 通过状态机实现并发 Future 如果需要同时运行多个 Future 或链式调用多个 Future，也可以通过无内存分配的状态机实现：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 trait SimpleFuture { type Output; fn poll(\u0026amp;mut self, wake: fn()) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt;; } enum Poll\u0026lt;T\u0026gt; { Ready(T), Pending, } /// 一个SimpleFuture，它会并发地运行两个Future直到它们完成 /// /// 之所以可以并发，是因为两个Future的轮询可以交替进行，一个阻塞，另一个就可以立刻执行，反之亦然 pub struct Join\u0026lt;FutureA, FutureB\u0026gt; { // 结构体的每个字段都包含一个Future，可以运行直到完成. // 等到Future完成后，字段会被设置为 `None`. 这样Future完成后，就不会再被轮询 a: Option\u0026lt;FutureA\u0026gt;, b: Option\u0026lt;FutureB\u0026gt;, } impl\u0026lt;FutureA, FutureB\u0026gt; SimpleFuture for Join\u0026lt;FutureA, FutureB\u0026gt; where FutureA: SimpleFuture\u0026lt;Output = ()\u0026gt;, FutureB: SimpleFuture\u0026lt;Output = ()\u0026gt;, { type Output = (); fn poll(\u0026amp;mut self, wake: fn()) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt; { // 尝试去完成一个 Future `a` if let Some(a) = \u0026amp;mut self.a { if let Poll::Ready(()) = a.poll(wake) { self.a.take(); } } // 尝试去完成一个 Future `b` if let Some(b) = \u0026amp;mut self.b { 从 SimpleFuture 到 Rust 真实的 Future 前面的 SimpleFuture 帮助我们理解了核心思想，但 Rust 标准库中真实的 Future trait 有两个关键区别：\n1 2 3 4 5 6 7 8 9 10 11 trait Future { type Output; fn poll( // 1. `self`的类型从`\u0026amp;mut self`变成了`Pin\u0026lt;\u0026amp;mut Self\u0026gt;`: // Pin 保证 Future 在内存中不会被移动，这对包含自引用的异步状态机至关重要 self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, // 2. `wake: fn()` 变成了 `cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;`: // Context 内部包含一个 Waker，不仅能唤醒任务，还能携带额外的调度信息 cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;, ) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt;; } 从这里开始，后续所有代码都将使用这个真实的 Future trait。\n实现自定义 Future：Delay 下面来实现一个具体的 Future，它将：1. 等待某个特定时间点的到来 2. 在标准输出打印文本 3. 生成一个字符串\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; struct Delay { when: Instant, } // 为我们的 Delay 类型实现 Future 特征 impl Future for Delay { type Output = \u0026amp;\u0026#39;static str; fn poll(self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;) -\u0026gt; Poll\u0026lt;\u0026amp;\u0026#39;static str\u0026gt; { if Instant::now() \u0026gt;= self.when { // 时间到了，Future 可以结束 println!(\u0026#34;Hello world\u0026#34;); // Future 执行结束并返回 \u0026#34;done\u0026#34; 字符串 Poll::Ready(\u0026#34;done\u0026#34;) } else { // 目前先忽略下面这行代码 cx.waker().wake_by_ref(); Poll::Pending } } } #[tokio::main] async fn main() { let when = Instant::now() + Duration::from_millis(10); let future = Delay { when }; // 运行并等待 Future 的完成 let out = future.await; 注意这里的 cx.waker().wake_by_ref() —— 它在返回 Pending 的同时立刻通知执行器\u0026quot;我还没好，但你可以马上再来问我\u0026quot;。这本质上是一种忙轮询，后面我们会看到它的问题以及如何改进。\n编译器如何处理 async fn 我们已经知道 async fn 不会立即执行，它返回一个 Future：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 use tokio::net::TcpStream; async fn my_async_fn() { println!(\u0026#34;hello from async\u0026#34;); let _socket = TcpStream::connect(\u0026#34;127.0.0.1:3000\u0026#34;).await.unwrap(); println!(\u0026#34;async TCP operation complete\u0026#34;); } #[tokio::main] async fn main() { let what_is_this = my_async_fn(); // 上面的调用不会产生任何效果 // ... 执行一些其它代码 what_is_this.await; // 直到 .await 后，文本才被打印，socket 连接也被创建和关闭 } 那编译器是怎么做到的？秘密在于：编译器会将 async fn 的函数体编译成一个枚举状态机。每个 .await 点就是一个状态分界。\n以前面使用 Delay 的 main 函数为例，编译器生成的代码类似：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; enum MainFuture { // 初始化，但永远不会被 poll State0, // 等待 `Delay` 运行，例如 `future.await` 代码行 State1(Delay), // Future 执行完成 Terminated, } impl Future for MainFuture { type Output = (); fn poll(mut self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;) -\u0026gt; Poll\u0026lt;()\u0026gt; { use MainFuture::*; loop { match *self { State0 =\u0026gt; { let when = Instant::now() + Duration::from_millis(10); let future = Delay { when }; *self = State1(future); } State1(ref mut my_future) =\u0026gt; { match Pin::new(my_future).poll(cx) { Poll::Ready(out) =\u0026gt; { assert_eq!(out, \u0026#34;done\u0026#34;); *self = Terminated; return Poll::Ready(()); 编译器会将 Future 变成状态机，其中 MainFuture 包含了 Future 可能处于的状态：从 State0 状态开始，当 poll 被调用时，Future 会尝试去尽可能的推进内部的状态，若它可以被完成时，就会返回 Poll::Ready，其中还会包含最终的输出结果\n这就是 async/await 的零成本抽象——没有堆分配，没有动态调度，只是一个普通的 enum + loop + match。\n构建执行器 async fn 返回 Future，而 Future 是惰性的，需要一个执行器（Executor） 来不停地 poll 推动它们直到完成。接下来我们从零构建一个执行器，经历两个版本的演进。\nV1：忙轮询 最简单的执行器实现：用一个 VecDeque 存放所有任务，不断取出来 poll，没完成就塞回去：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 fn main() { let mut mini_tokio = MiniTokio::new(); mini_tokio.spawn(async { let when = Instant::now() + Duration::from_millis(10); let future = Delay { when }; let out = future.await; assert_eq!(out, \u0026#34;done\u0026#34;); }); mini_tokio.run(); } struct MiniTokio { tasks: VecDeque\u0026lt;Task\u0026gt;, } type Task = Pin\u0026lt;Box\u0026lt;dyn Future\u0026lt;Output = ()\u0026gt; + Send\u0026gt;\u0026gt;; impl MiniTokio { fn new() -\u0026gt; MiniTokio { MiniTokio { tasks: VecDeque::new(), } } /// 生成一个 Future并放入 mini-tokio 实例的任务队列中 fn spawn\u0026lt;F\u0026gt;(\u0026amp;mut self, future: F) where F: Future\u0026lt;Output = ()\u0026gt; + Send + \u0026#39;static, { self.tasks.push_back(Box::pin(future)); } fn run(\u0026amp;mut self) { 这个执行器能跑，但有个严重问题：它不停地 poll 所有任务，即使绝大部分 Future 并没有准备好。这就像老板每 5 秒钟就问你\u0026quot;做完了吗？\u0026quot;——巨大的 CPU 浪费。\n我们需要的是一种**\u0026ldquo;通知 → 运行\u0026rdquo;**机制：Future 在无法继续时安静等待，一旦就绪主动通知执行器，执行器再去 poll。这就是 Waker 存在的意义。\n引入 Waker：从忙轮询到按需唤醒 回顾 Future::poll 的签名：\n1 2 fn poll(self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt;; Context 参数中包含 waker() 方法，返回一个绑定到当前任务上的 Waker。Waker 上定义了 wake() 方法，用于通知执行器：我准备好了，可以再来 poll 我了。\n有了 Waker，Future 就不需要忙轮询了。以定时器为例，我们可以实现一个 TimerFuture：在 poll 返回 Pending 时将 Waker 存下来，然后启动一个计时线程，等时间到了由计时线程调用 waker.wake() 来通知执行器。\n首先定义共享状态和 Future 结构体：\n1 2 3 4 5 6 7 8 9 10 11 12 pub struct TimerFuture { shared_state: Arc\u0026lt;Mutex\u0026lt;SharedState\u0026gt;\u0026gt;, } /// 在Future和等待的线程间共享状态 struct SharedState { /// 定时(睡眠)是否结束 completed: bool, /// 当睡眠结束后，线程可以用`waker`通知`TimerFuture`来唤醒任务 waker: Option\u0026lt;Waker\u0026gt;, } Future 的具体实现：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 impl Future for TimerFuture { type Output = (); fn poll(self: Pin\u0026lt;\u0026amp;mut Self\u0026gt;, cx: \u0026amp;mut Context\u0026lt;\u0026#39;_\u0026gt;) -\u0026gt; Poll\u0026lt;Self::Output\u0026gt; { // 通过检查共享状态，来确定定时器是否已经完成 let mut shared_state = self.shared_state.lock().unwrap(); if shared_state.completed { Poll::Ready(()) } else { // 设置`waker`，这样新线程在睡眠(计时)结束后可以唤醒当前的任务，接着再次对`Future`进行`poll`操作, // // 下面的`clone`每次被`poll`时都会发生一次，实际上，应该是只`clone`一次更加合理。 // 选择每次都`clone`的原因是： `TimerFuture`可以在执行器的不同任务间移动，如果只克隆一次， // 那么获取到的`waker`可能已经被篡改并指向了其它任务，最终导致执行器运行了错误的任务 shared_state.waker = Some(cx.waker().clone()); Poll::Pending } } } 代码很简单，只要新线程设置了 shared_state.completed = true，那任务就能顺利结束。如果没有设置，会为当前的任务克隆一份 Waker，这样新线程就可以使用它来唤醒当前的任务。\n最后，创建一个 API 用于构建定时器和启动计时线程：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 impl TimerFuture { /// 创建一个新的`TimerFuture`，在指定的时间结束后，该`Future`可以完成 pub fn new(duration: Duration) -\u0026gt; Self { let shared_state = Arc::new(Mutex::new(SharedState { completed: false, waker: None, })); // 创建新线程 let thread_shared_state = shared_state.clone(); thread::spawn(move || { // 睡眠指定时间实现计时功能 thread::sleep(duration); let mut shared_state = thread_shared_state.lock().unwrap(); // 通知执行器定时器已经完成，可以继续`poll`对应的`Future`了 shared_state.completed = true; if let Some(waker) = shared_state.waker.take() { waker.wake() } }); TimerFuture { shared_state } } } 对比之前的 Delay（用 wake_by_ref() 立刻通知，本质是忙轮询），TimerFuture 实现了真正的事件驱动——只有当计时线程 sleep 结束后，才会调用 waker.wake() 通知执行器。在等待期间，CPU 可以去做别的事情。\n有了事件驱动的 Future，执行器也需要相应升级：不再盲目遍历所有任务，而是被动等待被唤醒的任务到来。\nV2：基于消息通道的执行器 V1 用 VecDeque 主动遍历所有任务，V2 改用消息通道（channel）：执行器阻塞在接收端等待，只有被 wake() 唤醒的任务才会被送入通道、被 poll。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 /// 任务执行器，负责从通道中接收任务然后执行 struct Executor { ready_queue: Receiver\u0026lt;Arc\u0026lt;Task\u0026gt;\u0026gt;, } /// `Spawner`负责创建新的`Future`然后将它发送到任务通道中 #[derive(Clone)] struct Spawner { task_sender: SyncSender\u0026lt;Arc\u0026lt;Task\u0026gt;\u0026gt;, } /// 一个Future，它可以调度自己(将自己放入任务通道中)，然后等待执行器去`poll` struct Task { /// 进行中的Future，在未来的某个时间点会被完成 /// /// 按理来说`Mutex`在这里是多余的，因为我们只有一个线程来执行任务。但是由于 /// Rust并不聪明，它无法知道`Future`只会在一个线程内被修改，并不会被跨线程修改。因此 /// 我们需要使用`Mutex`来满足这个笨笨的编译器对线程安全的执着。 /// /// 如果是生产级的执行器实现，不会使用`Mutex`，因为会带来性能上的开销，取而代之的是使用`UnsafeCell` future: Mutex\u0026lt;Option\u0026lt;BoxFuture\u0026lt;\u0026#39;static, ()\u0026gt;\u0026gt;\u0026gt;, /// 可以将该任务自身放回到任务通道中，等待执行器的poll task_sender: SyncSender\u0026lt;Arc\u0026lt;Task\u0026gt;\u0026gt;, } fn new_executor_and_spawner() -\u0026gt; (Executor, Spawner) { // 任务通道允许的最大缓冲数(任务队列的最大长度) // 当前的实现仅仅是为了简单，在实际的执行中，并不会这么使用 const MAX_QUEUED_TASKS: usize = 10_000; let (task_sender, ready_queue) = sync_channel(MAX_QUEUED_TASKS); (Executor { ready_queue }, Spawner { task_sender }) } 下面再来添加一个方法用于生成 Future，然后将它放入任务通道中：\n1 2 3 4 5 6 7 8 9 10 impl Spawner { fn spawn(\u0026amp;self, future: impl Future\u0026lt;Output = ()\u0026gt; + \u0026#39;static + Send) { let future = future.boxed(); let task = Arc::new(Task { future: Mutex::new(Some(future)), task_sender: self.task_sender.clone(), }); self.task_sender.send(task).expect(\u0026#34;任务队列已满\u0026#34;); } } 接下来是关键：如何让任务在被 wake() 时把自己送回通道？答案是为 Task 实现 ArcWake 特征：\n1 2 3 4 5 6 7 8 9 10 impl ArcWake for Task { fn wake_by_ref(arc_self: \u0026amp;Arc\u0026lt;Self\u0026gt;) { // 通过发送任务到任务管道的方式来实现`wake`，这样`wake`后，任务就能被执行器`poll` let cloned = arc_self.clone(); arc_self .task_sender .send(cloned) .expect(\u0026#34;任务队列已满\u0026#34;); } } 当任务实现了 ArcWake 特征后，它就变成了 Waker，在调用 wake() 对其唤醒后会将任务复制一份所有权（Arc），然后将其发送到任务通道中。最后我们的执行器将从通道中获取任务，然后进行 poll 执行：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 impl Executor { fn run(\u0026amp;self) { while let Ok(task) = self.ready_queue.recv() { // 获取一个future，若它还没有完成(仍然是Some，不是None)，则对它进行一次poll并尝试完成它 let mut future_slot = task.future.lock().unwrap(); if let Some(mut future) = future_slot.take() { // 基于任务自身创建一个 `LocalWaker` let waker = waker_ref(\u0026amp;task); let context = \u0026amp;mut Context::from_waker(\u0026amp;*waker); // `BoxFuture\u0026lt;T\u0026gt;`是`Pin\u0026lt;Box\u0026lt;dyn Future\u0026lt;Output = T\u0026gt; + Send + \u0026#39;static\u0026gt;\u0026gt;`的类型别名 // 通过调用`as_mut`方法，可以将上面的类型转换成`Pin\u0026lt;\u0026amp;mut dyn Future + Send + \u0026#39;static\u0026gt;` if future.as_mut().poll(context).is_pending() { // Future还没执行完，因此将它放回任务中，等待下次被poll *future_slot = Some(future); } } } } } 对比 V1 和 V2 的核心区别：\nV1（忙轮询） V2（按需唤醒） 数据结构 VecDeque\u0026lt;Task\u0026gt; channel::Sender/Receiver 调度方式 不断 pop_front → poll → 没完成就 push_back 阻塞在 recv()，只有被 wake() 送回通道的任务才会被 poll CPU 开销 大量无效 poll，CPU 空转 无任务时线程休眠，零开销等待 核心区别 执行器主动轮询所有任务 任务主动通知执行器\u0026quot;我准备好了\u0026quot; 运行定时器 Future 下面再来写一段代码使用该执行器去运行之前的定时器 Future：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 fn main() { let (executor, spawner) = new_executor_and_spawner(); // 生成一个任务 spawner.spawn(async { println!(\u0026#34;howdy!\u0026#34;); // 创建定时器Future，并等待它完成 TimerFuture::new(Duration::new(2, 0)).await; println!(\u0026#34;done!\u0026#34;); }); // drop掉任务，这样执行器就知道任务已经完成，不会再有新的任务进来 drop(spawner); // 运行执行器直到任务队列为空 // 任务运行后，会先打印`howdy!`, 暂停2秒，接着打印 `done!` executor.run(); } 整体运行流程总结 以上面的 main 函数为例，把 Spawner、Executor、Task、TimerFuture、ArcWake 这些组件串起来，完整的执行流程如下：\n第一阶段：初始化\nnew_executor_and_spawner() 创建一个 sync_channel，Executor 持有接收端（Receiver），Spawner 持有发送端（SyncSender）。 第二阶段：提交任务\nspawner.spawn(async { ... }) 将 async 块包装成 BoxFuture，再封装进 Task（Task 内部也持有一份 channel 发送端），然后通过 task_sender.send(task) 将任务发送到通道中。 drop(spawner) 销毁 Spawner，此时只剩 Task 内部还持有发送端。这保证了：当所有 Task 都执行完毕后，通道的所有发送端都会被 drop，recv() 会返回 Err，执行器循环自然退出。 第三阶段：首次 poll\nexecutor.run() 启动循环，从通道 recv() 取出 Task。 执行器基于 Task 的 ArcWake 实现创建一个 Waker，再构造 Context，然后对 Future 进行第一次 poll。 async 块开始执行，打印 \u0026quot;howdy!\u0026quot;。 遇到 TimerFuture::new(Duration::new(2, 0)).await： TimerFuture::new() 创建 SharedState（completed: false, waker: None），并 spawn 一个新线程，该线程开始 sleep 2 秒。 .await 触发 TimerFuture::poll()，检查 shared_state.completed → false。 将当前 Waker 克隆后存入 shared_state.waker，返回 Poll::Pending。 async 块也返回 Poll::Pending，执行器将 Future 放回 Task 中。 执行器循环继续调用 recv()，此时通道为空，线程阻塞等待。 第四阶段：唤醒与再次 poll\n2 秒后，计时线程醒来，获取锁，设置 shared_state.completed = true。 调用 waker.take() 取出之前存储的 Waker，执行 waker.wake()。 wake() 触发 Task 的 ArcWake::wake_by_ref 实现 → 将 Task 自身（Arc\u0026lt;Task\u0026gt;）重新发送到通道中。 执行器的 recv() 收到任务，解除阻塞，对 Future 进行第二次 poll。 async 块从上次 .await 的位置恢复执行，TimerFuture::poll() 检查 shared_state.completed → true，返回 Poll::Ready(())。 async 块继续往下执行，打印 \u0026quot;done!\u0026quot;，整个 async 块返回 Poll::Ready(())。 第五阶段：退出\nFuture 已完成，执行器不会将其放回 Task。此时 Task 被 drop，其内部持有的 channel 发送端也随之 drop。 通道所有发送端均已关闭，recv() 返回 Err，while let 循环退出，程序结束。 核心机制可以归纳为三个字：等、通知、跑。Future 在无法完成时注册 Waker 然后让出控制权（等），外部条件就绪时通过 Waker 将 Task 重新送入通道（通知），执行器从通道取出 Task 再次 poll 推动执行（跑）。这就是 Rust 异步运行时的本质——基于 Waker 的按需唤醒机制，避免了忙轮询的 CPU 浪费。\n用一张图来概括：\n执行器和系统 IO 前面的 TimerFuture 用了一个专门的线程来做计时和唤醒。但在真实的网络场景中，不可能为每个 socket 都创建一个线程去轮询数据是否就绪——那样性能太低了。\n回顾之前的 SocketRead 例子中 set_readable_callback(wake) 是怎么工作的？现实中，这往往是通过操作系统提供的 IO 多路复用机制来完成（Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP），可以实现一个线程同时阻塞地去等待多个异步 IO 事件，一旦某个事件完成就立即退出阻塞并返回数据。\n这样，我们只需要一个执行器线程，它会接收 IO 事件并将其分发到对应的 Waker 中，接着后者会唤醒相关的任务，最终通过执行器 poll 后，任务可以顺利地继续执行，这种 IO 读取流程可以不停的循环，直到 socket 关闭。\n本章总结 回顾整个演进过程：\n1. async fn 的本质\nasync fn 并不会立即执行，它只是返回一个实现了 Future trait 的状态机。调用 my_async_fn() 相当于创建了一个\u0026quot;待执行的计划\u0026quot;，只有被 .await 或执行器 poll 时才会真正推进。\n2. 编译器做了什么\n编译器将 async fn 中每个 .await 点作为分界，把函数体拆分为一个枚举状态机（如 State0 → State1 → Terminated）。每次 poll 时通过 match 推进到下一个状态，这就是 async/await 的零成本抽象——没有堆分配，没有动态调度，只是一个普通的 enum + loop + match。\n3. 四个核心组件的协作\nFuture：状态机，每次 poll 推进一步，未完成时注册 Waker 后返回 Pending Waker：Future 和 Executor 之间的桥梁，外部事件通过它通知执行器 Executor：驱动循环，从通道接收就绪任务并 poll 外部事件源：真正的异步来源（定时器、IO、网络等），负责在条件就绪时调用 wake() 4. 与真实 Tokio 的对比\n我们的 mini 执行器已经具备了核心骨架，真实的 Tokio 在此基础上增加了：\n多线程调度器：work-stealing 算法，多个工作线程从共享队列中窃取任务 IO 驱动：基于 epoll/kqueue/IOCP 的 IO 多路复用，替代我们手动 spawn 线程的方式 时间驱动：时间轮（timing wheel）管理大量定时器，而非每个定时器一个线程 协作式调度：通过预算（budget）机制防止单个任务长时间霸占线程 但万变不离其宗——poll + Waker + Executor 这三位一体的模式，就是 Rust 异步的全部核心。\nTokio Rust 语言本身只提供了异步编程所需的基本特性，例如 async/await 关键字，这些特性单独使用没有任何用处，因此我们需要一个运行时来将这些特性实现的代码运行起来。目前最受欢迎的异步运行时就是 tokio。\nTokio 不适用的场景 并行运行 CPU 密集型的任务 tokio 非常适合于 IO 密集型任务，这些 IO 任务的绝大多数时间都用于阻塞等待 IO 的结果。但是如果是 CPU 密集型（例如并行计算），不建议通过 tokio 创建异步任务来执行它；因为 tokio 是协作式的调度器，如果某个 CPU 密集的异步任务是通过 tokio 创建的，那理论上来说，该异步任务需要跟其它的异步任务交错执行，最终大家都得到了执行。但是 CPU 密集的任务很可能会一直霸着 CPU，此时 tokio 的调度方式决定了该任务会一直被执行，这意味着，其它的异步任务无法得到执行的机会，最终这些任务都会因为得不到资源而饿死。\n但是可以使用 spawn_blocking 创建一个阻塞的线程去完成相应 CPU 密集任务，其会创建一个单独的 OS 线程，并不会被 tokio 所调度，它所执行的 CPU 密集任务也不会导致 tokio 调度的那些异步任务被饿死。\n读取大量的文件 读取文件的瓶颈主要在于操作系统，因为 OS 没有提供异步文件读取接口，大量的并发并不会提升文件读取的并行性能，反而可能会造成不可忽视的性能损耗，因此建议使用线程（或线程池）的方式。\n发送少量 HTTP 请求 tokio 的优势是给予你并发处理大量任务的能力，对于这种轻量级 HTTP 请求场景，tokio 除了增加你的代码复杂性，并无法带来什么额外的优势。\n基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use mini_redis::{client, Result}; #[tokio::main] async fn main() -\u0026gt; Result\u0026lt;()\u0026gt; { // 建立与mini-redis服务器的连接 let mut client = client::connect(\u0026#34;127.0.0.1:6379\u0026#34;).await?; // 设置 key: \u0026#34;hello\u0026#34; 和 值: \u0026#34;world\u0026#34; client.set(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;.into()).await?; // 获取\u0026#34;key=hello\u0026#34;的值 let result = client.get(\u0026#34;hello\u0026#34;).await?; println!(\u0026#34;从服务器端获取到结果={:?}\u0026#34;, result); Ok(()) } .await 表示等待操作执行完毕；但是 .await 会将操作切到后台去等待，当前线程不会被阻塞，它会接着执行其它的 task。一旦之前的操作准备好可以继续执行后，它会通知执行器，然后执行器会调度它并从上次离开的点继续执行。\n如果没有使用 await，而是按照这个异步的流程使用通知 -\u0026gt; 回调的方式实现，类似 Java 的 whenComplete，存在大量冗余模版代码。\n#[tokio::main] 原理 #[tokio::main] 宏将 async fn main 隐式的转换为 fn main 的同时还对整个异步运行时进行了初始化。例如以下代码：\n1 2 3 4 #[tokio::main] async fn main() { println!(\u0026#34;hello\u0026#34;); } 将被转成：\n1 2 3 4 5 6 fn main() { let mut rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { println!(\u0026#34;hello\u0026#34;); }) } 创建异步任务 一个 Tokio 任务是一个异步的绿色线程，它们通过 tokio::spawn 进行创建，该函数会返回一个 JoinHandle 类型的句柄，调用者可以使用该句柄跟创建的任务进行交互。\n1 2 3 4 5 6 7 8 9 #[tokio::main] async fn main() { let handle = tokio::spawn(async { 10086 }); let out = handle.await.unwrap(); println!(\u0026#34;GOT {}\u0026#34;, out); } tokio::spawn 生成的任务必须实现 Send 特征，因为当这些任务在 .await 执行过程中发生阻塞时，Tokio 调度器会将任务在线程间移动。\n一个任务要实现 Send 特征，那它在 .await 调用的过程中所持有的全部数据都必须实现 Send 特征。当 .await 调用发生阻塞时，任务会让出当前线程所有权给调度器，然后当任务准备好后，调度器会从上一次暂停的位置继续执行该任务。该流程能正确地工作，任务必须将 .await 之后使用的所有状态保存起来，这样才能在中断后恢复现场并继续执行。\n例如以下代码可以工作：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use tokio::task::yield_now; use std::rc::Rc; #[tokio::main] async fn main() { tokio::spawn(async { // 语句块的使用强制了 `rc` 会在 `.await` 被调用前就被释放， // 因此 `rc` 并不会影响 `.await`的安全性 { let rc = Rc::new(\u0026#34;hello\u0026#34;); println!(\u0026#34;{}\u0026#34;, rc); } // `rc` 的作用范围已经失效，因此当任务让出所有权给当前线程时，它无需作为状态被保存起来 yield_now().await; }); } 但是下面代码就不行：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 use tokio::task::yield_now; use std::rc::Rc; #[tokio::main] async fn main() { tokio::spawn(async { let rc = Rc::new(\u0026#34;hello\u0026#34;); // `rc` 在 `.await` 后还被继续使用，因此它必须被作为任务的状态保存起来 yield_now().await; // 事实上，注释掉下面一行代码，依然会报错 // 原因是：是否保存，不取决于 `rc` 是否被使用，而是取决于 `.await`在调用时是否仍然处于 `rc` 的作用域中 println!(\u0026#34;{}\u0026#34;, rc); // rc 作用域在这里结束 }); } 报错：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 error: future cannot be sent between threads safely --\u0026gt; src/main.rs:6:5 | 6 | tokio::spawn(async { | ^^^^^^^^^^^^ future created by async block is not `Send` | ::: [..]spawn.rs:127:21 | 127 | T: Future + Send + \u0026#39;static, | ---- required by this bound in | `tokio::task::spawn::spawn` | = help: within `impl std::future::Future`, the trait | `std::marker::Send` is not implemented for | `std::rc::Rc\u0026lt;\u0026amp;str\u0026gt;` note: future is not `Send` as this value is used across an await --\u0026gt; src/main.rs:10:9 | 7 | let rc = Rc::new(\u0026#34;hello\u0026#34;); | -- has type `std::rc::Rc\u0026lt;\u0026amp;str\u0026gt;` which is not `Send` ... 10 | yield_now().await; | ^^^^^^^^^^^^^^^^^ await occurs here, with `rc` maybe | used later 11 | println!(\u0026#34;{}\u0026#34;, rc); 12 | }); | - `rc` is later dropped here 异步同步共存 如何在同步代码中使用一小部分异步代码。\n在 Rust 中，main 函数不能是异步的，而之前我们通过 async fn main + #[tokio::main] 的声明，是因为 #[tokio::main] 仅仅是提供语法糖，目的是让大家可以更简单、更一致的去写异步代码，它会将你写下的 async fn main 函数替换为：\n1 2 3 4 5 6 7 8 9 fn main() { tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap() .block_on(async { println!(\u0026#34;Hello world\u0026#34;); }) } 注意到上面的 block_on 方法，在我们自己的同步代码中，可以使用它开启一个 async/await 世界。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 use tokio::runtime::Builder; use tokio::time::{sleep, Duration}; fn main() { let runtime = Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .unwrap(); let mut handles = Vec::with_capacity(10); for i in 0..10 { handles.push(runtime.spawn(my_bg_task(i))); } // 在后台任务运行的同时做一些耗费时间的事情 std::thread::sleep(Duration::from_millis(750)); println!(\u0026#34;Finished time-consuming task.\u0026#34;); // 等待这些后台任务的完成 for handle in handles { // `spawn` 方法返回一个 `JoinHandle`，它是一个 `Future`，因此可以通过 `block_on` 来等待它完成 runtime.block_on(handle).unwrap(); } } async fn my_bg_task(i: u64) { let millis = 1000 - 50 * i; println!(\u0026#34;Task {} sleeping for {} ms.\u0026#34;, i, millis); sleep(Duration::from_millis(millis)).await; println!(\u0026#34;Task {} stopping.\u0026#34;, i); } ","permalink":"https://luoyuxia.github.io/posts/rust%E5%BC%82%E6%AD%A5%E7%BC%96%E7%A8%8B-+-tokio/","summary":"本文系统讲解Rust异步编程原理与Tokio运行时，涵盖async/await机制、Future状态机实现、Waker唤醒模型、执行器从忙轮询到按需唤醒的演进，以及Tokio适用场景与最佳实践。","title":"Rust：异步编程 + Tokio"},{"content":"一句话概述 将 AI 记忆与 git 分支一一绑定，切换 git 分支时记忆自动跟随切换。每个任务拥有独立的上下文空间，排查问题时的错误路径不会污染开发分支，解决多任务并行时 AI 记忆相互干扰与上下文膨胀的问题。\n业务背景和痛点 背景 Claude Code 内置了 auto memory 机制——通过项目级的 MEMORY.md 文件在对话间持久化关键信息（架构决策、文件路径、踩坑记录等）。每次新对话开始时，Claude 会读取 MEMORY.md 作为上下文，从而\u0026quot;记住\u0026quot;之前的工作。\n但在实际研发中，我们很少只做一件事。以我自己的日常开发为例，我经常需要：\n功能开发：在分支 A 上和 Claude 深度讨论了 Fluss 对 Paimon Deletion Vector 的支持方案，积累了大量上下文。需求优先级调整后切到分支 B 做其他功能，几周后又需要回来继续 Paimon Deletion Vector 方案，之前的上下文已经被其他任务的记忆淹没。 紧急排查：线上报了 OOM，需要临时切到 debug-oom 分支排查 Code Review：Review 同事的 PR，切到 review-xxx 分支 这三件事分属不同的 git 分支，但 Claude Code 的 MEMORY.md 只有一份。\n痛点 痛点 1：记忆污染\n排查 OOM 时 Claude 记录了\u0026quot;怀疑是缓存泄漏 → 不是\u0026quot;、\u0026ldquo;可能是连接池 → 也不是\u0026quot;等试错路径。切回功能开发分支后，这些无关信息仍然留在 MEMORY.md 中，占用 token 配额，干扰 Claude 的注意力。\n痛点 2：错误路径残留\n排查过程中的错误假设被永久写入 MEMORY.md。下次 Claude 读到\u0026quot;怀疑是缓存泄漏\u0026quot;时可能误以为这是一个有效结论，导致后续判断偏差。\n痛点 3：上下文膨胀不可控\n一个月后 MEMORY.md 积累了十几个任务的记忆，相互交叉。想清理但不敢删——不知道哪条还有用，也不知道它是什么时候、因为什么原因加进来的。\n痛点 4：切换成本高\n每次切换任务都需要手动告诉 Claude\u0026quot;忘掉之前的上下文\u0026quot;或手动编辑 MEMORY.md，效率低且容易遗漏。\n使用方式 安装（一条命令） 1 2 3 4 5 6 # 全局安装 CLI uv tool install agent-memory # 在目标项目中安装 skills + hooks + CLAUDE.md cd /path/to/your-project agent-memory install 一条命令自动配置好 Skills、Hooks、CLAUDE.md 和数据目录，开箱即用。\n核心机制：记忆分支 = git 分支 安装后无需手动操作，一切自动运行：\n你切 git 分支（git checkout support-paimon-dv） 下次发消息给 Claude Code 时，UserPromptSubmit Hook 检测到 git 分支变化 Hook 自动执行：保存当前记忆 → 创建/切换到对应的记忆分支 → 加载新分支的 MEMORY.md 对话结束时，Stop Hook 自动保存当前 MEMORY.md 到分支 1 2 3 4 5 6 7 8 git checkout support-hudi → MEMORY.md 自动加载支持 hudi 的实现计划 git checkout debug-oom → MEMORY.md 自动切换为 OOM 排查的上下文 git checkout support-Blob-Type → MEMORY.md 恢复为 Blob Type 的内容，OOM 排查的记忆完全隔离 记忆写入保障 通过 CLAUDE.md 中的强规则指令，要求 Claude 每轮回复前先按需更新 MEMORY.md，而非等到对话结束。这确保了即使对话意外中断，之前轮次的记忆也已持久化。\nSlash Commands 命令 功能 /memory-status 查看当前记忆分支状态 /memory-save 总结当前对话并保存 /memory-history 查看记忆变更历史 /memory-create \u0026lt;name\u0026gt; 手动创建记忆分支 /memory-switch \u0026lt;name\u0026gt; 手动切换记忆分支 /memory-debug \u0026lt;topic\u0026gt; 创建临时排查分支 /memory-done \u0026lt;结论\u0026gt; 结束排查，发布结论到 main 并清理临时分支 发布与拉取：跨任务的知识沉淀 每个记忆分支是隔离的，但有价值的经验不应该被锁在某个分支里。agent-memory 借鉴了 git 的 publish/pull 模型，用 main 分支作为团队知识库：\npublish：将当前任务中提炼出的结论发布到 main 分支。只发布结论，不发布过程中的试错和废弃方案。 pull：在任意分支上从 main 拉取最新的共享知识，让新任务也能受益于之前的经验。 典型场景：排查完一个 OOM 问题后，过程中的\u0026quot;怀疑是缓存泄漏\u0026quot;\u0026ldquo;可能是连接池\u0026quot;等错误假设留在临时分支里，只把最终结论发布到 main：\n1 agent-memory publish \u0026#34;OOM root cause: BatchReader batch size 无上限，修复为 LIMIT 1000 (PR #234)\u0026#34; 之后在任何分支上执行 agent-memory pull，都能拉取到这条经验。这样 main 分支逐渐积累成一份干净的项目知识沉淀，没有噪音，只有结论。\n关键交付物 1. agent-memory CLI 工具 通过 uv tool install 或 pip install 安装 提供 18 个子命令覆盖完整工作流 2. Claude Code 集成 组件 数量 说明 Skills 7 个 斜杠命令，覆盖状态查看、保存、切换、排查等场景 Hooks 2 个 UserPromptSubmit（自动切换）+ Stop（自动保存） CLAUDE.md 模板 1 份 记忆写入规则，确保 Claude 主动记录 一键安装器 1 个 agent-memory install 自动配置目标项目 效果评估 定性改进 指标 改进前 改进后 上下文纯净度 所有任务记忆混杂在同一个 MEMORY.md 每个 git 分支独立记忆，互不干扰 错误路径处理 排查过程的试错永久残留 临时分支隔离，只 publish 结论，其余随分支删除 切换成本 手动编辑 MEMORY.md 或口头告知 Claude git checkout 自动触发记忆切换，零手动操作 记忆可追溯性 不知道何时添加、为何添加 history diff 精确定位每次变更 清理安全性 直接编辑 MEMORY.md，怕丢信息 有完整快照历史，支持 restore 回滚 定量估算 以日常开发场景（同时进行 2-3 个任务，每天切换 3-5 次）为基准估算：\n指标 估算值 说明 MEMORY.md 有效信息占比 从 ~40% 提升到 ~95% 排除无关任务记忆和错误路径后的有效占比 记忆清理频率 从每月 1-2 次降到基本不需要 临时分支用完即删，main 只保留结论 上下文 token 节省 ~30-50% 隔离后每个分支的 MEMORY.md 只包含本任务相关内容 实际使用示例 在 Apache Fluss 项目中，使用 agent-memory 后的典型工作流：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 09:00 git checkout support-paimon-dv → MEMORY.md 自动加载: \u0026#34;需改 8 个文件...\u0026#34; → Claude 无缝继续昨天的支持 paimon dv 实现 11:00 收到线上告警，git checkout debug-oom → MEMORY.md 自动切换为空（新分支），Claude 专注排查 → 排查完成: agent-memory publish \u0026#34;OOM root cause: unbounded batch size\u0026#34; 11:30 git checkout support-paimon-dv → MEMORY.md 恢复 paimon dv 上下文，OOM 排查内容完全不可见 → Claude 继续 paimon dv 实现，零切换成本 14:00 git checkout review-236 → MEMORY.md 自动切换，Claude 进入 PR Review 上下文 → Review 结论自动逐轮写入 MEMORY.md，author 修复 comment 之后可继续切回来 review 整个过程中没有一次手动编辑 MEMORY.md，记忆隔离完全自动化。\n","permalink":"https://luoyuxia.github.io/posts/%E7%BB%99-ai-agent-%E7%9A%84%E5%A4%A7%E8%84%91%E8%A3%85%E4%B8%8A-git-%E5%88%86%E6%94%AF%E7%AE%A1%E7%90%86/","summary":"本文介绍agent-memory工具，通过将AI记忆与Git分支一一绑定，实现多任务间上下文自动隔离、错误路径不污染、记忆按需沉淀，大幅提升AI编程的上下文纯净度与协作效率。","title":"给 AI Agent 的大脑装上 git 分支管理"},{"content":"现代大语言模型可以被看作一类统一的基础模型，而 GPT-1 到 GPT-3 构成了这一路线的连续推进：GPT-1 回答了“预训练能否形成可迁移通用能力”，GPT-2 展示了“模型可直接从文本中学习任务模式”，GPT-3 则让 prompt 这种用法变得更稳定、更普遍，也更接近真正可用的任务接口。\n沿着这条路线，现代 LLM 的三个基础环节被逐步建立起来：先通过预训练获得通用能力，再从大规模文本中学习任务模式，最后通过 prompt 直接调用这些能力。\n这一阶段可以进一步归结为三个技术问题：\n为什么自回归语言建模（根据前面的 token 预测下一个 token）可以成为通用训练目标？\n为什么只靠“预测下一个 token”，模型最终还能学会各种具体任务？\n为什么到了更大规模时，不更新参数、只改输入里的说明和示例，模型也能更稳定地把任务做出来？\nGPT-1[2]、GPT-2[3]、GPT-3[5] 分别给出了比较清晰的回答。后续围绕模型行为约束、外部知识接入、执行能力扩展和模态拓展的大多数工作，都建立在这些答案已经成立的前提之上。\n从训练方式和使用方式看，这条路线至少包含四个稳定特征：\n统一目标：以自回归语言建模作为主训练目标。\n统一架构：以 decoder-only Transformer[1] 作为主要参数化结构。\n统一训练路径：先在海量无标注语料上预训练，再迁移到下游任务。\n模型怎么被使用：从每个任务都要单独微调，逐步过渡到直接通过上下文调用。\n因此，GPT-1 到 GPT-3 对应的是基础模型层的建立，要解释现代 LLM 在训练时到底学了什么、能力是怎么来的、又是怎么被调用的，就必须先解释这一阶段。\n一、问题背景：为什么传统 NLP 范式不能直接推出 LLM 在 GPT 系列之前，NLP 的主流工作流以任务为中心。典型路径如下：\n先定义任务要模型做什么，以及输出结果怎么表示\n为特定任务设计模型结构。\n在监督数据上训练或微调模型。\n以该任务的专用指标进行评估。\n这种范式存在几个结构性限制：\n监督依赖强：模型性能与高质量标注数据强相关，长尾任务的数据获取和标注成本都很高。\n模型复用性弱：不同任务往往对应不同结构、不同训练流程、不同损失设计。\n迁移效率低：任务之间共享的语义和知识很难稳定地积累到同一套参数里。\n接口不统一：任务能力主要由训练过程定义，而不是由自然语言输入直接调用。\nGPT 路线的核心变化在于：研究对象从“任务”切换为“语言分布”。其基本假设是，如果模型能够充分拟合大规模自然语言分布，那么下游任务所需的大量知识、模式与映射关系，可能已经包含在这一分布内部。\n换言之，GPT 路线并不首先问“怎样为每个任务设计模型”，而是先问“怎样训练一个统一模型，使其从文本分布中学习尽可能通用的能力”。这也就是后来基础模型路线的出发点。\n二、GPT-1：为什么自回归预训练能够成为通用能力的起点 1. GPT-1 要解决什么问题 GPT-1 的核心问题是：\n海量无标注文本能否先被模型学进去，再通过少量监督迁移到不同下游任务？\n这个问题的重要性在于，它直接决定了 NLP 是否能够从“任务专用训练”转向“通用预训练 + 任务适配”。\n2. GPT-1 采用了什么技术路线 GPT-1 采用 decoder-only Transformer（只看前文、持续预测下一个 token 的 Transformer），训练目标为标准自回归语言建模：\nP(x_t | x_1, x_2, ..., x_{t-1})\n训练时，模型在预测当前 token 时只能看前面的内容，不能看到后面的 token，因此它学到的是“根据前文预测下一个 token”。这一路线有几个关键技术性质：\n目标统一：所有文本都可以转写为“预测下一个 token”的训练样本。\n数据可扩展：不依赖人工标注，大规模文本可以直接提供训练信号。\n参数共享充分：生成与迁移使用同一组基础参数，而不是为每个任务单独构造主体结构。\n知识沉淀一致：模型学习的是语言统计、语义模式和条件生成规律，而不是单任务判别边界。\n这里的关键不在于“生成”本身，而在于生成式目标提供了一种统一的、自监督的、可规模化的学习机制。GPT-1 的结论是：这一路线能够为分类、蕴含、问答等下游任务提供有效初始化，并在微调后显著优于从零训练。\n3. GPT-1 把哪种训练方式确定了下来 GPT-1 的主要贡献可以归纳为三点：\n确立预训练主阶段：预训练不再是辅助特征工程，而是能力形成的主体阶段。\n确立统一基础模型：同一模型可以迁移到多个下游任务，而不是为每个任务重建主干网络。\n确立生成式路线：语言理解能力可以通过生成式建模间接获得，而不必依赖任务专用理解目标。\n4. GPT-1 的边界在哪里 GPT-1 仍然属于“预训练 + 显式微调”范式，其局限也很明确：\n任务适配仍依赖微调：到了具体任务上，通常还要重新训练模型。\n自然语言还不是主要接口：用户还不能只靠说明和示例直接调用模型能力。\n直接做任务的能力还不稳定：不经过微调时，模型还不能稳定完成新任务\n因此，GPT-1 建立的是“先预训练、再迁移”的训练方式，而不是统一的任务调用方式。\n三、GPT-2：为什么只预测下一个 token 也能学会任务 1. GPT-2 要继续回答什么问题 GPT-2 向前推进的问题是：\n在保持同一架构和同一训练目标的前提下，仅通过扩大模型与数据规模，语言模型能否直接表现出多任务能力？\n这对应的研究重心已经从“预训练是否有效”转向“模型在大规模文本里究竟学到了什么”。\n2. GPT-2 延续了什么，又改变了什么 GPT-2 沿用了 GPT-1 的主路线：\ndecoder-only Transformer\n自回归语言建模目标\n海量无标注文本训练\n因此，GPT-2 的关键不在“更换范式”，而在于对同一范式进行规模化推进，并观察能力形态是否发生变化。它的重要变化主要来自三个方面：\n模型规模扩大：模型参数容量显著提升，能够容纳更复杂的统计结构。\n训练数据扩展：语料更接近开放域网页文本（但并非无筛选全网语料），其中包含大量自然形成的问题-回答、标题-正文、说明-结果等弱监督模式。\n评估方式变化：重点从“微调后效果”扩展到“在不更新参数时模型能否完成任务”。\n这里最关键的变化是训练数据更接近真实互联网文本。这样的文本里本来就有大量现成的任务例子，比如问题后面跟答案、标题后面跟正文、说明后面跟结果。模型在学习这些文本时，不只是学词和词怎么接在一起，也会逐渐学会：遇到什么样的输入，后面通常应该接什么样的输出。\n3. 为什么 GPT-2 开始表现出任务能力 GPT-2 的关键结论可以概括为：\n许多下游任务可以被重写为文本条件生成问题，而语言模型能够直接学习这类映射。\n这意味着任务表示方式开始变化：\n在传统做法里，一个任务通常要先准备专门的数据，再明确模型输出什么结果，并按这个任务单独训练。\n到了 GPT-2，任务不一定非要先写成专门的数据集再训练，很多时候只要把问题、上下文和示例写进输入里，模型就会开始按这个任务去生成。\n换句话说，GPT-2 开始不只是学“下一个词该怎么接”，还会学“这段输入看起来像是在做什么任务”。\n这里要区分训练和推理两个阶段：\n训练时，模型会在大规模文本里见到大量“问题后面接答案”“标题后面接正文”“说明后面接结果”这样的写法；\n推理时，只要你给它一个问题，或者给它几个“问题—回答”的示例，它就更容易把当前输入当成同一种任务，然后按这个模式继续生成。比如，如果输入是一个问题，后面通常应该接答案；如果输入是一句英文，后面跟着中文，模型就更容易把它当成翻译任务；如果前面已经给了两三个输入输出示例，模型也会更容易继续按这个格式生成下去。\n这里最关键的变化有两点：\n模型开始从自然文本里学会一些常见任务的写法，比如提问后面接回答、标题后面接正文、说明后面接结果。\n即使没有专门为某个任务重新训练，它也开始能够在一些场景下直接照着这些写法把任务做下去。\n这也就是论文标题里 “Unsupervised Multitask Learner” 的意思：模型并不是先被明确教会每一个任务，而是在大量文本中先见过各种任务长什么样，然后在生成时把这些模式用出来。\n4. GPT-2 还缺什么 GPT-2 虽然已经表现出多任务潜力，但这时还远没有到“拿来就很好用”的程度：\n同一个任务，换一种提问方式，效果就可能明显变化。\n有些例子里它能做对，但换一个相近例子，结果就不一定稳定。\n它已经开始会照着输入里的任务写法往下做，但还不能像后来的模型那样，只靠上下文就比较稳定地进入任务状态。\n因此，GPT-2 更准确的定位是：它已经让人看到了“模型可以直接从文本里学会做任务”这件事，但这种能力当时还不够稳定，也还不够容易直接使用。\n四、GPT-3：为什么到了这一步，prompt 开始成为更稳定的任务调用方式 1. GPT-3 要解决什么问题 GPT-3 要继续回答的问题是：\n如果继续扩大模型规模，那么“不更新参数也能做任务”这件事，能不能变得更稳定、更普遍，并真正成为一种可用的任务调用方式？\n这一步的重要性在于，它直接改变了基础模型的使用方式。\n2. In-Context Learning 到底意味着什么 in-context learning 在 GPT-2 中已经出现了苗头，而到了 GPT-3，它开始变得足够稳定、足够普遍，因此可以被当成这一代模型最重要的现象之一。其基本形式是：\n在输入中给出任务说明；\n在输入中给出少量示例；\n模型根据这些说明和示例，判断当前任务的输入和输出该怎么对应；\n模型在不更新参数的前提下生成目标输出。\n从优化角度看，推理阶段并没有发生显式梯度更新；从行为角度看，模型却表现出临时适配任务的能力。因此，in-context learning 更准确的描述不是“模型在推理时训练了自己”，而是：\n模型利用预训练时已经学到的任务样例模式，在当前上下文里判断应该按什么规则继续生成。\nGPT-2 里这种能力已经出现，但到了 GPT-3，随着模型规模、数据规模和训练计算量继续扩大，模型更容易从 prompt 里识别任务意图、从少量示例里抓住输入输出规律，并在生成时把这种规律保持得更稳定。\n这意味着 prompt 的角色发生了变化。它不再只是把问题输给模型，而是在同时告诉模型：现在要做什么、该按什么格式回答、前面的例子说明了什么规则。\n3. 为什么到了 GPT-3，prompt 开始成为更稳定的任务调用方式 原因不在于 GPT-3 突然学会了一种全新的机制，而在于 GPT-2 中已经出现的现象，在更大模型、更多数据和更多训练计算量下变得更稳定了。模型规模更大之后，它更容易同时保留和调用不同任务的写法；见过的数据更多之后，它也更容易从 prompt 里认出用户到底是在让它做问答、翻译、分类，还是按示例继续生成。\n所以，GPT-3 最关键的变化在于：GPT-2 已经让人看到，不重新训练模型也可能做任务；而到了 GPT-3，这件事开始变得更稳定、更普遍，也更像一种真正可用的使用方式。这个变化体现在三个方面：\nfew-shot learning 变得更可用：给几个示例，模型更容易照着这个任务稳定地做下去。\n任务可以直接写成说明：很多任务不必先做成专门训练流程，直接用自然语言描述就行。\n面对新任务时，第一步不再总是微调：很多时候可以先写 prompt，看看模型能不能直接做，而且成功率比前一代更高。\n如果对比前两代，变化会更清楚：在 GPT-1 中，做新任务主要还是靠 fine-tuning；在 GPT-2 中，不更新参数也能做一些任务，但效果还不稳定；到了 GPT-3，很多任务已经可以通过“自然语言说明 + 几个示例”更可靠地直接做起来。\n从实际使用上看，GPT-2 已经让人看到这种用法的苗头；而到了 GPT-3，很多不同任务才开始更像真的可以不先重新训练、直接把说明和示例写进输入里就试起来。\n4. Scaling Laws 为什么是 GPT-3 的重要背景 GPT-3 没有引入全新的基础架构，但其行为与前代相比已经发生明显变化。要理解这一点，不能只看参数数量，还要看当时研究者对“继续做大是否值得”这件事的认识。Scaling Laws[4] 提供的正是这样一个背景：参数规模、数据规模和训练计算量与模型效果之间，存在相对稳定的经验关系。\nScaling Laws 工作的核心意思可以直接概括成三点。\n第一，模型变大通常会带来更低的损失。 也就是说，在训练目标不变的情况下，只要规模继续扩大，模型效果往往会沿着比较平滑的趋势继续提升，而不是随机波动。\n第二，参数、数据和训练计算量不是各堆各的。 如果模型很大但数据不够，或者数据很多但模型太小，效果都不会最好。真正重要的是三者之间的配比。\n第三，固定预算下也存在更优的训练方案。 这意味着“要不要继续做大”不再只是拍脑袋，而是可以根据经验规律来估算下一步大概值不值得。\n所以，Scaling Laws 的意义不只是告诉大家“更大通常更强”，而是把“继续做大”这件事从一种工程直觉，推进成了一条可以分析、可以比较、可以规划的技术路线，为 GPT-3 一类规模化实验提供了投入依据。\n而对 GPT-3 来说，更关键的是，这种规模扩张带来的不只是训练指标上的提升，还会直接体现在模型的使用表现上：\n模型更容易从少量示例中总结输入输出规律；\n模型更容易保持输出格式和行为一致性；\n模型也更容易把已经学到的语言和知识用到不同任务上。\n因此，GPT-3 的关键不只是“更大模型效果更好”，而是：\n当模型跨过一定规模区间后，自回归语言模型开始更稳定地表现出“给几个例子就能按要求做事”的能力。\n五、从 GPT-1 到 GPT-3：这条路线到底发生了什么变化 如果将 GPT-1、GPT-2、GPT-3 放在同一条技术线上，可以看到三次明确的迁移。\n1. GPT-1：建立了“预训练 + 微调”的基本训练方式 这是 GPT-1 建立的。\n模型先在大规模无标注文本上做预训练。\n到了具体任务上，再通过微调把能力迁移过去。\n这样同一个模型就可以成为多个任务的共同起点。\n2. GPT-2：开始出现“不微调也能做任务”的现象，但还不稳定 这是 GPT-2 推动的。\n模型开始从自然文本里学会一些常见任务的写法。\n在一些场景下，即使不重新微调，它也能直接把任务做一点。\n但这种能力还很依赖 prompt 写法，稳定性也不够。\n3. GPT-3：把这种现象推进成更稳定、更普遍的使用方式 这是 GPT-3 推动的。\n不更新参数做任务这件事开始变得更稳定。\nprompt 和示例开始更像一种真正可用的调用方式。\n很多任务可以先不微调，直接通过说明和示例来尝试完成。\n这三次变化连起来，就是现代基础模型最基本的工作链条：\n先通过预训练学到通用能力 -\u0026gt; 再从大规模文本中学会任务怎么做 -\u0026gt; 最后通过 prompt 直接把这些能力用起来\n六、GPT-1 / GPT-2 / GPT-3 技术对照表 维度 GPT-1 GPT-2 GPT-3 核心问题 无标注文本能否先被模型学进去，再迁移到不同任务 语言模型能否直接从文本中学会任务模式 不更新参数做任务这件事，能否变得更稳定、更普遍、更可用 核心结论 生成式预训练 + 微调有效 语言模型开始表现出零样本多任务潜力 in-context learning 在更多任务上展现出可用性 任务适配 下游 fine-tuning 零样本 / 少样本 prompt 开始出现，但不稳定 \u0026ldquo;自然语言说明 + 示例\u0026quot;开始成为更可靠的主要方式 怎么使用 主要靠训练和微调 开始依赖提示格式 很多任务开始主要靠上下文和 prompt 主要限制 迁移有效，但高度依赖微调 能表现任务模式，但鲁棒性不足 能通过上下文适配任务，但仍受提示设计与规模限制 关键贡献 建立先预训练再迁移的训练方式 证明模型会从文本中学会任务模式 证明模型能力可以通过上下文直接调用 在这条路线中的作用 建立训练基础 建立能力基础 建立调用基础 七、总结 GPT-1、GPT-2、GPT-3 的技术意义可以压缩为三个结论：\nGPT-1：生成式预训练可以作为通用能力的起点。\nGPT-2：模型开始能从大规模文本里学会一些任务该怎么做。\nGPT-3：很多任务开始可以不重新训练，只靠 prompt 和示例就更稳定地做起来。\n因此，从 GPT-1 到 GPT-3，现代大语言模型最关键的三步被依次建立：\n第一步：先用自监督预训练学到通用语言能力。\n第二步：再从大规模文本中学会各种任务模式。\n第三步：最后通过 prompt 在不微调的情况下调用这些能力。\n后续围绕行为控制、知识增强、系统集成和模态扩展的工作，并没有改变这一层的基本逻辑，而是在其上继续扩展系统能力。\n参考文献 【1】Attention Is All You Need\n【2】Improving Language Understanding by Generative Pre-Training\n【3】Language Models are Unsupervised Multitask Learners\n【4】Scaling Laws for Neural Language Models\n【5】Language Models are Few-Shot Learners\n","permalink":"https://luoyuxia.github.io/posts/%E4%BB%8E-gpt-1-%E5%88%B0-gpt-3%E7%8E%B0%E4%BB%A3%E5%A4%A7%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B%E7%9A%84%E6%8A%80%E6%9C%AF%E5%BA%95%E5%BA%A7%E6%98%AF%E5%A6%82%E4%BD%95%E5%BD%A2%E6%88%90%E7%9A%84/","summary":"GPT-1至GPT-3逐步确立了现代大语言模型的三大基础：预训练获得通用能力、从文本中学习任务模式、通过prompt实现零微调的任务调用。","title":"从 GPT-1 到 GPT-3：现代大语言模型的技术底座是如何形成的"},{"content":"有段时间没有更新文章了，并不是因为我在憋什么大招。只不过是因为这段时间，随着 AI，尤其是 Vide Coding 的爆发，我现在对大数据 Infra 是提不起一点兴趣，不管啥框架，挂个 Claude，一分钟就能给你学完。\n步入正题，今天来学习下大模型推理框架 - SGLang。\n第一部分：大语言模型基础 在理解 SGLang 之前，我们需要先搞清楚大语言模型（LLM）到底在做什么。\n1.0 大语言模型的本质——下一个词的预测器 一句话：大模型的本质就是一个\u0026quot;下一个词预测器\u0026quot;——给定前面的所有文字，预测下一个最可能出现的词。\n我们可以把 LLM 想象成一个读过几乎整个互联网的\u0026quot;超级填空选手\u0026quot;。当你给它一句话\u0026quot;今天天气真____\u0026quot;，它会计算词表中每一个候选词出现在这个位置的概率：\n1 2 3 4 5 6 7 \u0026#34;好\u0026#34; → 35.2% \u0026#34;不错\u0026#34; → 28.7% \u0026#34;棒\u0026#34; → 12.1% \u0026#34;热\u0026#34; → 8.4% \u0026#34;差\u0026#34; → 3.2% \u0026#34;香蕉\u0026#34; → 0.0001% ... 然后通过采样策略（greedy、top-k、top-p 等）选择一个 Token 输出。这就是 LLM 做的全部事情——预测下一个 token 的分布，然后从中采样。\n为什么输出看起来\u0026quot;有智能\u0026quot;？因为训练语料库的规模达到了数万亿 Token（书籍、网页、代码、论文），模型参数量达到数百亿甚至万亿级别。在这个规模下，模型学到的条件概率分布已经精确到足以捕获语言中绝大多数的语义关联和逻辑模式。当它看到\u0026quot;中国的首都是\u0026quot;，训练数据中这个前缀后面几乎 100% 跟的是\u0026quot;北京\u0026quot;，因此模型输出基本不会出现毫无关联的词。\n理解了这个本质后，后面的技术细节就有了根基：Transformer 是实现这个概率模型的基本架构，自回归生成是使用它的方式，而 KV Cache 和 SGLang 的各种优化，都是为了让这个\u0026quot;预测下一个 Token\u0026quot;的过程在高并发场景下跑得更快。\n1.1 Transformer 与注意力机制 Transformer 是当前几乎所有大语言模型的底层架构（GPT、LLaMA、DeepSeek 等都基于它）。它的结构是多层堆叠的 Block，每个 Block 主要由两部分组成：Self-Attention 层 和 FFN（前馈网络）层。其中 Self-Attention 是 Transformer 区别于早期 RNN/LSTM 的核心机制——它让模型能够直接建模序列中任意两个位置之间的依赖关系，而不需要像 RNN 那样逐步传递。\n在 Transformer 之前，主流的序列模型是 RNN（循环神经网络）。RNN 按时间步顺序处理序列：第 1 个词的输出作为隐状态传给第 2 个词，第 2 个词的隐状态再传给第 3 个词，以此类推。这意味着第 100 个词要\u0026quot;感知\u0026quot;第 1 个词的信息，必须经过 99 步传递，信息在传递过程中不断衰减（长距离依赖问题），而且由于步骤之间有严格的先后依赖，无法并行计算。\nSelf-Attention 彻底改变了这一点：每个 Token 直接与序列中的所有 Token 计算关联度，不需要逐步传递。第 100 个词可以一步直接“看到”第 1 个词。这既解决了长距离依赖问题，又使得整个序列可以并行计算，充分利用 GPU 的并行能力。\nSelf-Attention 的计算过程 每个 Token 的 Embedding 向量经过三个不同的线性变换（即乘以三个权重矩阵 W_Q、W_K、W_V）得到三个向量，W_Q、W_K、W_V 是模型的可学习参数，在训练阶段从数据中学到——模型在海量文本上训练时，自动学会了如何将同一个 Token 投影到不同的子空间，使得 Q 擅长表达\u0026quot;需要什么信息\u0026quot;、K 擅长表达\u0026quot;能提供什么信息\u0026quot;、V 擅长携带语义内容。训练完成后这三个矩阵就固定下来，推理时直接使用：\n向量 含义 说明 Q（Query） 查询向量 代表当前 Token \u0026ldquo;想要寻找什么信息\u0026rdquo;。当模型处理到某个 Token 时，Q 用来向序列中所有其他 Token 发起查询 K（Key） 键向量 代表当前 Token \u0026ldquo;能提供什么信息\u0026rdquo;。K 是每个 Token 暴露给其他 Token 用于匹配的标识 V（Value） 值向量 代表当前 Token \u0026ldquo;实际携带的语义内容\u0026rdquo;。由 K 匹配完成后，真正被聚合的信息来自 V Q 和 K 用于计算\u0026quot;相关性\u0026quot;，V 用于提供\u0026quot;内容\u0026quot;。具体来说：当前 Token 的 Q 与序列中每个 Token 的 K 做点积，得到一组分数（score），表示当前 Token 对每个位置的关注程度；这组分数经过 softmax 归一化为权重后，对所有 Token 的 V 做加权求和，得到当前 Token 的输出。\nAttention 计算公式：Attention(Q,K,V) = softmax(QK^T / √d_k) · V，其中 √d_k 是缩放因子，防止点积值过大导致 softmax 梯度消失。\n举例：对于句子 \u0026ldquo;小猫坐在垫子上\u0026rdquo;，当模型处理 \u0026ldquo;坐\u0026rdquo; 这个 Token 时，它的 Q 会与所有 Token 的 K 匹配，发现与 \u0026ldquo;小猫\u0026rdquo;（主语）和 \u0026ldquo;垫子\u0026rdquo;（位置）的 K 匹配度高，于是在加权求和时，\u0026ldquo;小猫\u0026rdquo; 和 \u0026ldquo;垫子\u0026rdquo; 的 V 会贡献更多信息到 \u0026ldquo;坐\u0026rdquo; 的输出表示中。\n关键点：注意力的计算量随序列长度的平方增长——每个 Token 的 Q 都要与序列中所有 Token 的 K 计算一次点积，100 个 Token 意味着 100 个 Q 各自与 100 个 K 配对，共 100 × 100 = 10,000 次点积运算；1,000 个 Token 就是 1,000 × 1,000 = 1,000,000 次。序列长度增长 10 倍，计算量增长 100 倍。这是后面所有优化的根源之一。\n1.2 自回归生成 LLM 每次推理只生成一个 Token，然后将其拼回输入序列，迭代生成下一个。\nChatGPT 回答问题时文字逐个出现，这不是 UI 效果，而是模型工作原理决定的——自回归生成（Autoregressive Generation）：\n1 2 3 第 1 步：输入 \u0026#34;今天天气\u0026#34; → 模型输出 \u0026#34;真\u0026#34; 第 2 步：输入 \u0026#34;今天天气真\u0026#34; → 模型输出 \u0026#34;不\u0026#34; 第 3 步：输入 \u0026#34;今天天气真不\u0026#34; → 模型输出 \u0026#34;错\u0026#34; 每一步，模型都需要：\n接收目前为止的所有Token（用户输入 + 已生成的） 通过 Attention 计算它们之间的关系 输出一个新 Token 问题来了：回顾 1.1 节的 Attention 计算——要生成下一个 Token，模型需要用新 Token 的 Q 与序列中所有 Token 的 K 做点积、对所有 Token 的 V 做加权求和。这意味着序列中每个 Token 都必须有对应的 K 和 V 向量。如果不做任何缓存，每次推理都是一次独立的完整计算：模型拿到整个序列，为每个 Token 重新通过 W_K、W_V 矩阵计算 K 和 V。\n所以第 3 步处理 \u0026ldquo;今天天气真不\u0026rdquo; 时，模型会为这 6 个 Token 全部重新计算 K 和 V，但其中 \u0026ldquo;今天天气\u0026rdquo; 的 K、V 在第 1 步就算过了，\u0026ldquo;真\u0026rdquo; 的 K、V 在第 2 步也算过了。每一步都在重复计算已有 Token 的 K/V，这是巨大的浪费。\n1.3 KV Cache 缓存已计算的 K、V 向量，每步只为新 Token 计算增量，避免重复。\n既然之前 Token 的 K、V 向量每一步都在重复计算，自然的优化就是把它们缓存在 GPU 显存中：\n1 2 3 4 第 1 步算完后缓存： [K₁,V₁] [K₂,V₂] [K₃,V₃] [K₄,V₄] ← \u0026#34;今天天气\u0026#34; 第 2 步只需算新的： [K₁,V₁] [K₂,V₂] [K₃,V₃] [K₄,V₄] [K₅,V₅] ← 新增 \u0026#34;真\u0026#34; 第 3 步只需算新的： [K₁,V₁] [K₂,V₂] [K₃,V₃] [K₄,V₄] [K₅,V₅] [K₆,V₆] ← 新增 \u0026#34;不\u0026#34; \\_______________ 缓存复用 _______________/ \\_ 新计算 _/ 有了 KV Cache，每步只需计算一个新 Token 的 Q、K、V，然后用新的 Q 和缓存中所有的 K 做匹配。每步的计算量从 O(n^2) 降到 O(n)。\n但 KV Cache 有显著的内存代价：\n一个 70B 参数的模型，每个 Token 的 KV Cache 约占 1.25MB 一条 4K 上下文长度的请求，KV Cache 约 5GB 显存 一张 80GB 的 A100 GPU，去掉模型权重后，能同时服务的请求数非常有限 KV Cache 是 LLM 推理的核心矛盾——它是加速生成的必需品，同时也是显存的最大消耗者。如何管理 KV Cache，决定了推理系统的性能上限。\n1.4 Prefill vs Decode——推理的两个阶段 Prefill 是计算密集型（compute-bound），Decode 是访存密集型（memory-bound），两者对硬件的需求截然不同。\nLLM 处理一条请求分为两个阶段：\nPrefill（预填充） Decode（解码） 做什么 一次性并行处理用户的整段 prompt 逐个生成输出 Token 计算特点 并行处理所有输入 Token，计算量大 每步只处理 1 个新 Token，计算量小 瓶颈 计算密集型（GPU 算力是瓶颈） 访存密集型（显存带宽是瓶颈） GPU 利用率 高（大规模矩阵运算，算术强度高） 低（大部分时间在等数据搬运） 为什么 Decode 是访存密集型？因为每步只为 1 个新 Token 做计算，计算量很小，但这个计算需要的数据量却很大：模型的全部权重参数（70B 模型约 140GB）每步都要从显存加载到计算单元，同时还要读取整个序列的 KV Cache 来完成 Attention。计算量和数据搬运量的比值（算术强度）极低——GPU 的算力远未饱和，大部分时间在等数据从显存搬运过来。\n相比之下，Prefill 一次性并行处理数百甚至数千个 Token，同样加载一次模型权重，但对这些权重做了大量矩阵乘法运算，算术强度高，GPU 算力被充分利用。\n第二部分：大语言模型 LLM 推理服务的核心挑战 大部分人可能会想：\u0026ldquo;我直接用 Hugging Face Transformers 的 model.generate() 不就能跑模型了吗？为什么还需要 SGLang 这种推理服务？\u0026rdquo;\n答案是：单条请求跑起来没问题，但要同时服务成百上千个用户就完全不够了。Hugging Face Transformers 是为研究和原型验证设计的，它一次处理一条（或手动凑一个小 batch 的）请求，没有请求队列、没有动态批处理、没有 KV Cache 的跨请求复用、没有显存的精细管理。当并发请求增多时，你会遇到：显存很快耗尽（每条请求独立分配 KV Cache）、GPU 大量空转（一条请求生成完才处理下一条）、长请求阻塞短请求（没有调度策略）。\n具体来说，高并发 LLM 推理服务面临四大挑战：\n挑战一：内存瓶颈。KV Cache 占用大量显存。并发请求越多，显存消耗越大。当显存耗尽时，新请求只能排队等待，即使 GPU 算力还有空余。\n挑战二：计算瓶颈。Prefill 需要集中算力一次性处理长 prompt（compute-bound），Decode 需要频繁但轻量的计算（memory-bound）。两种负载特征完全不同，混在一起互相拖累。\n挑战三：调度瓶颈。请求的输入长度和输出长度差异巨大。如何安排处理顺序，才能在吞吐量、延迟、公平性之间取得平衡？\n挑战四：前缀重复。实际场景中，大量请求共享相同的前缀（如 System Prompt \u0026ldquo;你是一个有帮助的AI助手\u0026rdquo;）。每条请求都独立计算这部分的 KV Cache，造成大量重复计算和显存浪费。\nSGLang 的核心设计就是针对这四个挑战的系统性解决方案。\n第三部分：SGLang 的整体架构 3.1 整体架构 SGLang 的推理引擎在典型单节点部署下可以抽象为三个角色\n推理引擎\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 用户请求（HTTP / gRPC） │ ▼ ┌─────────────────────┐ │ TokenizerManager │ ← 分词 + 请求接入 │ 文字 → Token ID │ └────────┬────────────┘ │ ZMQ (IPC) ▼ ┌─────────────────────┐ │ Scheduler │ ← 调度 + GPU 计算（核心） │ 批次调度 + 模型推理 │ │ KV Cache 管理 │ └────────┬────────────┘ │ ZMQ ▼ ┌─────────────────────┐ │ DetokenizerManager │ ← 增量解码 │ Token ID → 文字 │ └────────┬────────────┘ │ ZMQ ▼ 返回给用户 TokenizerManager：运行在主进程。接收文字请求，分词为 Token ID 序列；处理图片等多模态输入\nScheduler：SGLang 的核心。决定哪些请求进入本轮计算、管理 KV Cache 的分配与回收、驱动 GPU 执行模型推理\nDetokenizerManager：将模型输出的 Token ID 增量转回文字，支持流式返回\n三个进程之间通过 ZMQ（ZeroMQ） 做 IPC 通信，轻量且解耦。\n3.2 请求的完整生命周期 请求完整生命周期\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1. 用户发送 \u0026#34;请解释量子计算\u0026#34; │ 2. TokenizerManager 分词 → [8785, 46267, 101312, 99882] │ 3. Scheduler 接收，加入 waiting_queue │ 4. Scheduler 根据调度策略选出本轮请求，组成 Batch │ 5. Prefill：GPU 并行计算所有输入 Token 的 KV Cache │ 6. Decode：GPU 逐步生成输出 Token（每步一个） │ ↘ 每生成一批 Token，发送给 DetokenizerManager │ 7. DetokenizerManager 增量解码 → \u0026#34;量子\u0026#34; \u0026#34;计算\u0026#34; \u0026#34;是\u0026#34; \u0026#34;一种\u0026#34; ... │ 8. 流式返回给用户（或攒齐后一次性返回） Scheduler 内部有三层数据抽象：\n1 2 ScheduleBatch → ModelWorkerBatch → ForwardBatch (CPU 调度数据) (CPU→GPU 桥接) (GPU 张量) 这个分层让调度逻辑（CPU 侧）和模型计算（GPU 侧）清晰解耦。\n第四部分：五大核心优化 4.1 RadixAttention — 自动前缀缓存 多个请求共享相同前缀时，KV Cache 只算一次，自动复用。\n实际场景中，大量请求共享相同的前缀：\n1 2 3 4 5 请求 A: [System Prompt] + \u0026#34;请解释量子计算\u0026#34; 请求 B: [System Prompt] + \u0026#34;请翻译这段话\u0026#34; 请求 C: [System Prompt] + \u0026#34;帮我写一首诗\u0026#34; ↑ 共享相同的 System Prompt → 传统做法各算一遍 KV Cache（3 倍浪费） SGLang 使用基数树（Radix Tree）管理 KV Cache。每个树节点存储一段 Token 序列对应的 KV Cache 在 GPU 显存中的位置索引：\n1 2 3 4 5 6 7 根节点 / \\ [System Prompt A] [System Prompt B] / \\ | \u0026#34;请解释\u0026#34; \u0026#34;请翻译\u0026#34; \u0026#34;写代码\u0026#34; | | | \u0026#34;量子计算\u0026#34; \u0026#34;这段话\u0026#34; \u0026#34;排序算法\u0026#34; 新请求到来时，沿树向下做前缀匹配：\n完全匹配 → 直接复用已缓存的 KV Cache，跳过这部分 Prefill 部分匹配→ 树节点自动分裂（_split_node），复用匹配部分，只计算新增部分 无匹配 → 正常计算，完成后插入树中供后续请求复用 显存有限，需要淘汰策略。SGLang 支持 6 种：LRU（默认）、LFU、FIFO、FILO、MRU、Priority。淘汰时使用最小堆从叶子节点开始回收，叶子被回收后若父节点变为空且未被锁定，则级联向上回收。\n4.2 Continuous Batching — 持续批处理 请求完成后立即移出 batch 释放资源，新请求随时加入，GPU 不空转。\n传统 Static Batching 将一组请求凑齐后一起处理，等全部完成再处理下一组。如果一条请求特别长，其余已完成的请求只能陪着空等，GPU 资源大量浪费：\nStatic Batching:\n1 2 3 4 请求1: ████████░░░░░░░░ ← 已完成，GPU 资源空置 请求2: ████████████████ ← 最长的 请求3: ████░░░░░░░░░░░░ ← 空等 时间 ──────────────────→ Continuous Batching 在每一步模型推理后检查：完成的请求立即移出并释放 KV Cache，等待中的请求立即加入填补空位：\nContinuous Batching:\n1 2 3 4 5 请求1: ████████ 请求3: ████ 请求5: ██████████ 请求4: ██████ 请求6: ████████ 请求2: ████████████████ 时间 ──────────────────→ 4.3 Chunked Prefill — 分块预填充 长 prompt 的 Prefill 拆分成多个 chunk，穿插执行 Decode，避免长请求独占 GPU。\n一个 100K Token 的长文档 Prefill 可能需要数秒 GPU 独占时间。期间所有 Decode 请求被阻塞，用户感受到明显卡顿。\nSGLang 将长 prompt 拆分成固定大小的 chunk（如 4096 Token），每个调度步只处理一个 chunk，Decode 请求可以在 chunk 之间穿插执行：\n1 2 3 4 5 6 7 8 9 10 不分块： 长请求 Prefill: ████████████████████████ ← 独占 GPU 短请求 Decode: ░░░░░░░░░░░░░░░░░░░░░░░░ ████ ← 被阻塞 分块后（chunk_size = 4096）： 长请求 chunk1: ████ 短请求 Decode: ██ 长请求 chunk2: ████ 短请求 Decode: ██ 长请求 chunk3: ████ 显著降低短请求的 TTFT（Time To First Token）\n4.4 Zero-Overhead Overlap Scheduling — 零开销重叠调度 CPU 调度与 GPU 计算流水线化，调度开销被 GPU 计算时间完全隐藏。\nSGLang 的 Scheduler 有两种事件循环模式：\n普通模式（event_loop_normal）——串行：\n1 接收请求 → 调度 → GPU forward → 处理结果 → 接收请求 → 调度 → ... 重叠模式（event_loop_overlap）——CPU/GPU 流水线：\n1 2 3 时间步 1: GPU forward batch₁ | CPU 空闲（首次启动） 时间步 2: GPU forward batch₂ | CPU 处理 batch₁ 结果 + 调度 batch₃ 时间步 3: GPU forward batch₃ | CPU 处理 batch₂ 结果 + 调度 batch₄ 效果：调度开销几乎为零。\n4.5 Speculative Decoding — 投机解码 用小模型快速猜多个 Token，大模型一次性验证，将逐 Token 生成变为批量验证。\nDecode 阶段每步只生成 1 个 Token，GPU 算力大量空闲（memory-bound）。投机解码的思路是：\nDraft 阶段：用小模型快速生成 k 个候选 Token Verify 阶段：大模型对这 k 个 Token 做一次推理来验证 Accept/Reject：从第一个被拒绝的 Token 处截断，接受之前的所有 Token 效果： Decode 速度提升 2-3x，数学上保证输出分布与原始大模型一致。\n为什么能保证分布一致？关键在于验证阶段的接受/拒绝策略。对于小模型提出的每个候选 Token x，大模型会计算自己在该位置的概率 p(x)，同时已知小模型给出的概率 q(x)。接受概率为 min(1, p(x)/q(x))：\n如果 p(x) \u0026gt;= q(x)（大模型认为该 Token 的概率不低于小模型的预测），则必定接受 如果 p(x) \u0026lt; q(x)（大模型认为概率更低），则以 p(x)/q(x) 的概率接受，否则拒绝 当某个 Token 被拒绝时，不是简单丢弃，而是从一个修正分布 norm(max(0, p(x) - q(x))) 中重新采样一个 Token 作为替代。可以证明，这个\u0026quot;接受或从修正分布重采样\u0026quot;的过程，使得最终每个位置输出的 Token 的边际分布严格等于大模型原始的 p(x)——无论小模型的质量如何。小模型越准，接受率越高，加速比越大；但即使小模型很差，输出质量也不会下降，只是退化到与原始逐 Token 生成相同的速度。\n第五部分：调度策略 Scheduler 的另一个职责是决定 waiting_queue 中哪些请求优先进入计算。SGLang 支持两类策略：\n缓存感知策略（与 RadixAttention 配合）：\nLPM（Longest Prefix Match）：优先调度在 Radix Tree 中能匹配到最长缓存前缀的请求，最大化 KV Cache 复用率 DFS-Weight：通过 DFS 遍历 Radix Tree 计算分支权重，按权重重排等待队列 缓存无关策略：\nFCFS：先来先服务 LOF（Longest Output First）：预期输出最长的先处理 Random 默认使用 LPM。当等待队列超过 128 条时自动退化为 FCFS，避免前缀匹配本身成为 CPU 瓶颈。\n这种缓存感知调度是 SGLang 的特色——调度策略不只考虑公平性，还要最大化已有缓存的复用率。\n第六部分：总结 SGLang 到底做了什么让 LLM 推理变快了？\nLLM 推理的根本矛盾在于：KV Cache 既是必需品又是最大的显存消耗者；Prefill 和 Decode 的硬件需求截然不同却共享同一套资源。SGLang 在三个维度同时优化：\n内存管理：RadixAttention 用基数树自动复用共享前缀的 KV Cache，减少显存浪费 计算调度：Continuous Batching 消除 GPU 空闲，Chunked Prefill 防止长请求阻塞短请求，Overlap Scheduling 将 CPU 调度与 GPU 计算流水线化 缓存复用：缓存感知调度策略（LPM）优先处理能最大化复用缓存的请求 最终目标：让 GPU 的每一个时钟周期都在做有价值的计算，而不是在等待、重复或空转。\n","permalink":"https://luoyuxia.github.io/posts/%E6%B5%85%E6%B5%85%E5%AD%A6%E4%B9%A0%E4%B8%80%E4%B8%8B%E5%A4%A7%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8Bllm%E6%8E%A8%E7%90%86%E6%A1%86%E6%9E%B6---sglang/","summary":"本文系统介绍了大语言模型推理框架SGLang，围绕其如何通过RadixAttention、ContinuousBatching、ChunkedPrefill等五大优化技术，解决KVCache内存瓶颈、Prefill/Decode负载不均及调度低效等核心挑战，显著提升高并发LLM推理效率。","title":"浅浅学习一下大语言模型（LLM）推理框架 - SGLang"},{"content":"介绍 SlateDB 是面向对象存储设计的，用 Rust 写的，基于 LSM-Tree 结构的 Embedded KV 存储 。SlateDB 整体设计类似 RocksDB，单 Writer，多 Readers，不同于 RocksDB 将数据写入本地磁盘，SlateDB 直接由 Writer 将数据写入对象存储中。\n为什么不直接基于 RocksDB SlateDB 是由 Kafka Streams 的那帮人搞的，Kafka Streams 类似于 Flink，在 Kafka 上做计算，也是用的 RocksDB 作为 state。但是他们发现直接用 RocksDB 作为 state 有一堆问题，恢复时间长，依赖本地存储各种问题。最佳方案就是把 state 都放到远程存储上去。\nKafka Streams 的那帮人一开始是基于 RocksDB 来魔改，但是改了一段时间，发现行不太通。他们发现 RocksDB 本身就是为本地 SSD 设计的，实现上有很多地方假设这一点，导致魔改起来非常困难。具体的原因如下：\n文件系统的 API 和 对象存储的 API 并不一致 虽然 RocksDB 本身抽象出来了和文件系统交互的接口，RocksDB 也搞了个 HDFS 的 Plugin，但是总归也只是面向文件系统的，并不能直接照搬到对象存储上来。\n比如：\nRocksDB 依赖文件系统的 link 机制来保存 checkpoint，对象存储没有这种机制\nRocksDB 依赖文件系统的锁来独占访问，但是对象存储并没有这样的锁，\nRocksDB 依赖文件系统自身的缓存来缓存写入的数据，后续读取直接从缓存中读取。但是对象存储也并没有这个机制\nRocksDB 假设所有数据都在本地，导致某些设计不够灵活 如果要支持 Remote Compaction 的话，实现起来就比较复杂。对于一个 RocksDB 实例，我们可能需要搞个 remote 进程来做 compaction 操作，但是同样也需要与打开这个 RocksDB 实例进行通信协调。\n基于一个给定的 RocksDB checkpoint，在不同的计算节点上 Open 这个 RocksDB 实例也很麻烦。需要下载到本地，然后再 Open。\n设计 基本概念 在理解 Slate 之前，我们需要理解 LSM-Tree 中比较重要的几个概念：\nWrite Ahead Log（WAL） WAL 是一个 Append-Only 的日志文件，数据的每一次写入（PUT）都会首先 append 到 WAL 中，WAL 主要是用来 crash 的时候恢复数据的。\nMemTables 数据的写入（PUT），首先会 append 到 WAL 中，然后会插入内存的中一个数据结构当中，这个内存的数据结构就叫做 MemTable。这个 MemTable 是排序过的，这样对于数据的 Get，就可以在 MemTable 中通过二分查找快速找到。\nSSTables MemTables 是在内存中的，数据总归是需要持久化到磁盘的，而持久化到磁盘的结构称为 SSTables（Sorted String Tables）。\n对于数据的 Get，会依次访问 MemTables，SSTables。\nManifests 有了 WAL 和 SSTables，LSM-Tree 还需要 Manifests 文件来记录 LSM-Tree 当前的状态，比如 LSM-Tree 当前包含哪些 SSTables，WAL 的恢复点（以便 Crash 后从 WAL 中恢复），等其他必要的信息。\nLSM-Tree 每一次状态的变化，比如增加了一个 SSTable，都会记录在 Manifests 文件中。\n文件组织 一个 SlateDB 实例的文件组织如下所示：\n1 2 3 4 5 6 7 8 9 10 11 12 13 path/to/db/ ├─ manifest/ │ ├─ 00000000000000000001.manifest │ ├─ 00000000000000000002.manifest │ └─ ... ├─ wal/ │ ├─ 00000000000000000001.sst │ ├─ 00000000000000000002.sst │ └─ ... └─ compacted/ ├─ 01K3XYV1W2WR4FDVB7A9S319YS.sst ├─ 01K3XYV9JFPSZ5BW3Y1DVMKDFS.sst └─ ... Manifest manifest 目录下是 manifest 文件列表，文件名字由 1 开始递增。manifest 会被如下的进程更新：\nWriters：当写了一个新的 WAL 或者 SST 文件的时候，会更新 manifest 让其包含这个新的文件\nReaders：Reader 读 LST-Tree 的一个快照时，需要更新 manifest 来表示读了哪个 snapshot，避免其他进程删掉这个 snapshot 对应的 SST\nCompactor：Compactor 会将若干 SST 文件 compact 成新的 SST 文件，compact 成功后需要更新 manifest 让其包含新的 SST 文件\nWAL \u0026amp; Comapcted WAL 目录下是 WAL 文件，WAL 文件名由 1 开始递增。\nCompacted 目录下是 SSTables 文件，文件名是一个 UUID。\n写流程 写流程如下所示：\n首先 put 到内存到 mutable WAL 中\n在 flush_ms 后，mutable WAL 变为 immutable WAL，异步写到 object storage中\n同时也会 copy 一份到 MemTable 中\n给 client 返回 ack\nmem table 中的数据量满足一定条件，变成 frozen memtable，异步flush 到 object storage中，作为 LSM 的 l0 层\n读流程 读流程如下所示：\n依次在 Mem Table，Frozen Memtable，SSTables 中寻找\n避免多写导致写入覆盖的问题 上面的写流程忽略了一个重要的问题，即：如何保证只有单个writer写。\nSlateDB 只允许同一时间单个 writer 写，但是依然存在可能会有多个 writer 尝试写，如果不做任何保护的话，会存在写入互相覆盖，导致写入丢失的问题。\n针对多写的问题，SlateDB 主要使用 CAS 来避免写入覆盖的问题。即如果一个 Writer A 准备写文件 1，但是发现另一个 Writer B 已经写了文件1，Writer A 就会 abort 这次写入。\n其实这依赖对象的 Put If Absent 的能力，即如果文件1不存在才写入，不然就不写入。值得一提的是，去年 S3 还不支持 Put If Absent ，不过 SlateDB 认为 S3 最终一定会支持的。今年果然就支持了。\n我想聊一下，在 S3 还不支持 Put If Absent 的能力的时候，SlateDB 是怎么做的。\nSlateDB 使用两阶段写的方式来支持，需要引入一个外部的 transactional store（DynamoDB）。\nwriter 首先写一个 object 到一个临时的 location，然后写一个 record （put if not exist）到 transactional store，包含 source，destination，completion flag。\nwriter 之后 copy 这个 object 到 destination location。copy 好了之后，transactional store 的这个 record 的 completion flag 就会被标记为 complete，最后将该临时 object 删掉；\n只要 record 被写入到了 transactional store，就认为写成功了，在这之前失败的话，就 abort 这次写入，在这之后失败的话，就走 recover 流程，重新 copy，然后设置 completion flag。\n这样，如果一个另一个 writer 准备写相同的 destination location，会发现 transactional store 已经有这个 record，就不会写入了。\n具体而言，SlateDB 在如下两种 Case 下需要避免多 writer 写入：\n更新 manifest\nSlateDB 更新 manifest 其实就是写一个更大 ID 的 manifest 文件，更新 manifest 需要保证原子性，不然会存在 Writer A 基于 manifest 1 写入了 manifest 2。 Writer B 也基于 manifest 1 写入了 manifest 2。这样 Writer A 的更新就丢失了。为了保证 manifest 更新的原子性，其更新流程如下所示：\nlist manifest 找到最大的 id，比如 00000000000000000002.manifest 读 00000000000000000002.manifest 的内容到内存中 在内存中更新manifest的内容 写 next manifest id manifest/00000000000000000003.manifest 步骤4 就是 CAS operation，如果 4 失败了，那么 client 就必须重复 1 ～ 4步，因为 client 现在内存中有的 manifest 就是过期的。\nWriter 写 WAL 文件\n类似的，写 WAL 文件也只能由一个 writer 来写入，不然也会存在 writer 互相覆盖的情况。SlateDB 通过 CAS 来避免写入覆盖，引入 writer epoch 来确定哪个 writer 可以写入，其中当前的 writer epoch 记录在 manifest 中，其整体流程如下所示：\nWriter A 启动的时候读当前的 manifest 递增 writer_epoch，并写到 manifest 中 Writer A 首先 list wal 目录找到下一个 SST 文件的 ID，然后带着 writer_epoch 使用 CAS 操作写这个 SST，会存在如下几种状态： 写成功了，这样所有其他有更低 writer epoch 的 writer 都不能写了 写失败了，另一个 writer B 写了一个相同 ID 的 SST，但是 writer B 有更低的 writer epoch，这表明 Writer A 是合法的写入，于是 Writer A 从步骤1 重新执行 写失败了，另一个 writer 写了一个相同 ID 的 SST 和相同的 writer_epoch，Writer A 直接 abort 自己 写失败了，另一个 writer 写了一个相同 ID 的 SST 和更高的 writer_epoch，说明另一个 Writer 正在写入，Writer A 直接 abort 自己，不再尝试写入，保证单 writer 写入 总结 基于对象存储重新实现的开源表格式，Log 系统很多，但是 KV 系统确实不多见，SlateDB 的实现有一定的参考价值\n对象存储很“弱”，基于对象存储设计的系统需要考虑到对象存储的独有特性\n对象存储的 Put If Absent 语义很强，要充分利用，有的时候可以避免引入额外的复杂度\n","permalink":"https://luoyuxia.github.io/posts/slatedb--%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E5%AD%98%E5%82%A8%E9%87%8D%E6%96%B0%E8%AE%BE%E8%AE%A1%E7%9A%84-rocksdb/","summary":"SlateDB是基于Rust和LSM-Tree、专为对象存储设计的嵌入式KV数据库，解决RocksDB在远程存储场景下的局限性。","title":"SlateDB: 面向对象存储重新设计的 RocksDB"},{"content":"本文内容来自于论文：A Deep Dive into Common Open Formats for Analytical DBMSs\n压缩和编码 在深入理解列存格式之前，我们需要先理解一下压缩和编码。数据存储/处理系统通常都需要减少原始数据的大小，以减少数据在磁盘\u0026amp;内存中占用的空间，并且减少 IO 次数。压缩和编码则是减少原始数据的大小的重要技术。但是注意的是，这也是一种取舍，因为这意味着读数据的时候，需要耗费额外的 CPU 时间对数据进行解压缩/编码。\n压缩 这种算法不理解数据本身，简单地将数据当作一个字节流，可以对各种类型的数据进行压缩，具有很强的通用性，但是比较消耗 CPU，典型的压缩算法有：GZIP，Snappy 等。\n编码 这种算法可以理解为一种更轻量的压缩，CPU 消耗不高的同时也能一定程度上对数据进行比较好的压缩。同时是对特定类型的数据进行编码，以达到压缩的效果。一些编码算法甚至可以允许计算引擎直接在编码过的数据进行查询。\n比较经典的编码算法有：\n位打包编码（Bit-Packed Encoding） 其分为如下步骤：\n1: 分析数值范围: 找到所有数值中的最大值\n2: 计算最小位宽: 确定表示最大值所需的最少位数\n3: 消除前导零: 移除所有数值中不必要的零位\n4: 紧凑存储: 将多个数值打包到一个字节中\n字典编码（Dictionary Encoding） 它维护一个字典，在编码的时候把要编码的字符串转换成字典里面这个字母对应的下标，而解码的时候则从这个下标还原成原来的字符。对于低基数字符串类型的列可以有效压缩。\nRun-Length Encoding（行程编码） 一组资料串\u0026quot;AAAABBBCCDEEEE\u0026quot;，由4个A、3个B、2个C、1个D、4个E组成，经过变动长度编码法可将资料压缩为4A3B2C1D4E。\n其优点在于将重复性高的资料量压缩成小单位；然而，其缺点在于─若该资料出现频率不高，可能导致压缩结果资料量比原始资料大，例如：原始资料\u0026quot;ABCDE\u0026quot;，压缩结果为\u0026quot;1A1B1C1D1E\u0026quot;（由5个单位转成10个单位）。\nDictionary-RLE 字典编码的基础上，对字典的 Key 使用 Run-Length Encoding，通常有更好的压缩效果。\n列存 不管是 Arrow，Parquet，还是 ORC 列存，都遵循如下的一个结构：\n表的所有数据首先被水平切分成若干个 RowBatch，每个RowBatch 包含多行。每个 RowBatch 再按照 列 进行切分成多个 Chunks，每列的数据对应一个 Chunk，Chunk 从第一列到最后一列按顺序摆放。\n然后会有个 metadata 来记录 RowBatch 的信息，包括 RowBatch 的 location，长度，压缩算法等，通常在文件的 footer，只需要读文件的 footer 就可以定位到 RowBatch，然后把这个 RowBatch 读出来。\nArrow Arrow 是一种内存列存数据格式，和文件列存格式 Parquet，ORC 互补；具有如下的特性：\n访问相同 chunked column 内的 entry 具有 O(1) 的复杂度，原理是：\n对于定长的数据类型，在内存中占用固定的长度，访问第 n 个entry，可以直接计算出偏移量，比如对于 int 类型（占 4 个字节），直接从 offset 为 n * 4 开始访问就可以\n对应变长的数据类型，比如 String 类型，维护一个 offset 数组，查询offset 数据得到其偏移量\n每个entry在内存中连续摆放，迭代 entry 高效\nArrow Feather 是 arrow 在磁盘上的存储格式，与 arrow 在内存中的格式一样，但是支持 Zstd，LZ4 压缩以减少在磁盘上的空间。\nParquet Parquet 的结构如下所示：\n一个 row batch 的一个 chunked column 被划分成了多个 Data Page；“Page的拆分，主要是从编码和压缩的角度，进行拆分，以page为单位进行压缩编码，也可以认为一定程度上起到了内存和CPU上用量的控制”。每个 DataPage 采用 dictionary encoding，如果 dictionary 变得很大的话，就使用 Plain encoding\n文件 footer 还包含每个 row batch 的统计信息（Zone Map），比如 min，max，null值的数量等；利用这些统计信息，可以进行 data skipping，避免读取不必要的数据。\nORC ORC 的结构如下所示：\nORC 的一个 strip 就是一个 RowBatch，每个 Strip 都包含一个 Index Data，Row Data，Stripe Footer。\nIndexData：列的 min/max 值，bloom filter 等； 列在 RowGroup 的起始位置和偏移量。\nRow Data：存储具体的数据\nStrip Footer：\n每一列的编码信息；\nstream 的 location。在存储上，一列由多个stream 组成，比如对于 Integer 列和 String 列，其表示如下：\n┌─────────────────────────────────────┐\n│ Column 1 (Integer): │\n│ ├─ PRESENT Stream (null 标记) │\n│ └─ DATA Stream (实际编码过后数据) │\n├─────────────────────────────────────┤\n│ Column 2 (String): │\n│ ├─ PRESENT Stream (null 标记) │\n│ ├─ Dictionary data (字典数据) │\n| ├─ Dictionary length (字典长度） │\n│ └─Encoded row data (实际编码过后数据) │\n└─────────────────────────────────────┘\nFile footer：strip 的location；每个strip 的行数；列的类型，列在 file level 的统计信息 ，min/max/sum 等；\nPostscipt：compression 信息\n编码的区别 如下是三种列存在默认编码格式的区别：\n值得注意的是 parquet v2 支持指定不同的 encoding 格式；https://issues.apache.org/jira/browse/PARQUET-601\n列存 Benchmark 结果比较 压缩率比较 只使用编码 在数据集上，对数据只使用编码算法，得到的结果如下表所示：\n总体来看：Parquet \u0026gt; ORC \u0026gt; Arrow-DICT \u0026gt; Arrow\n对于该结果的解释如下：\nArrow 没有任何编码，且有 metadata 的开销，比如对于 String 类型的数据而言，需要额外记录一下 String 的长度，所以压缩率最差\nArrow-DICT 采用了字典编码，在 String 类型的数据能很好的压缩，所以比 Plain Arrow 的压缩率高\nORC 在 Integer 类型和 Float 类型上使用 RLE 编码，在 benchmark 数据集上效果一般\nParquet 使用 DICT-RLE 编码，在 benchmark 数据集上压缩率最高\n使用编码 + 压缩 基于 TPC-DS 数据集进行测试。测试不使用压缩算法和使用不同的压缩算法下不同列格式的压缩率。\n对于 Integer 类型，压缩率如下所示：\n对于 Integers 类型，无压缩，只编码的情况下，ORC 压缩效果更好。原因是 ORC总是使用 RLE，在 TPC-DS 数据集 Integers 类型数据表现更好，而 Parquet 使用 DICT 编码，表现会更差。但是使用了压缩的话，表现都差不多。\n对于 Double 类型，压缩率如下所示：\nParquet 的表现会更好，原因是对于Doubles， ORC 不进行编码，Parquet 则使用 DICT 编码。\n对于 String 类型，压缩率如下所示：\n虽然 Parquet 和 ORC 都使用 DICT 编码，但是 Parquet 的表现依然要比 ORC 更好，原因如下：\nORC 的 Stripe 的 Size 更小，需要更多的 Dictionary\nORC 的 Stripe 的 Size 更小，相比于 Parquet 较大的 RowGroup，更容易会退到 plain 编码\n结论：虽然 Parquet 和 ORC 在不同的数据集，不同列类型上表现各不相同，但综合看下来，Parquet 的压缩率最好。\n压缩/解压的时间 压缩 基于 TPC-DS 数据集，将内存格式 Arrow 分别序列化成文件格式 Parquet，ORC，Arrow Feather 格式，结果如下图所示：\nArrow Feather 压缩时间最短，但是压缩后的 size 会更大，因为其没有 encoding；\nParquet 和 ORC 压缩后的 size 差不多，但是 Parquet 的 压缩时间比较短，论文认为是因为 Parquet 对 Arrow 有更好的支持，“arrow 和 parquet share same codebase and data structures”\n解压 将文件格式 Parquet，ORC，Arrow Feather 格式从文件中反序列成内存格式 Arrow，其结果如下所示：\nLZ4所需的时间更少，因为它需要更少的 Disk IO（文件更小），相对于其他解压缩算法，提供了较快的解压\nArrow 总是更快，因为它没有encoding；ORC 最慢，论文认为 ORC 更慢的原因是压缩的配置，比如 block size，buffer size等导致的\n从文件中反序列成内存格式 Arrow 会有 Disk IO 的干扰，论文排除 Disk IO 干扰（直接从内存中反序列成 Arrow 格式），得到如下的结果：\n可以看到，在所有的 Case 下， 都会变得更快。特别是对于没有压缩的 Arrow 格式而言。\n但是同时也可以看到，对于有压缩的 case，排除 Disk IO 并没有带来很大的提升，因为此时瓶颈在于 CPU，而不是从磁盘 Load 数据\n列访问效率的比较 列裁剪 projection 基于 TPC-DS 数据集，对于Integers 类型和 Doubles 类型的列，其裁剪的 benchmark 结果如下所示：\n对于 Integers，ORC 效率最高，因为它使用 RLE 编码，有更高的压缩率，因此需要更少的IO\nParquet 使用 DICT，效率较差，有额外的 Dict 加载 开销，decoding 也需要 lookup dict。\nArrow Feather 最差，需要先 load 所有列到内存，然后再在内存进行列裁剪\n而对于 String 类型的列，其裁剪的 benchmark 结果如下所示：\n对于 String，Arrow 反而更好；String下，Parquet 和 ORC 压缩效率没那么高，对 disk io 的 reduce 并不是决定性的，但是 parquet 和 orc 又带来了 decoding 开销；而 arrow 没有任何decoding 开销，效率更高；\n列 Filter 基于 TPC-DS 数据集，对于Integers 类型和 Doubles 类型的列进行过滤（过滤谓词可以过滤掉 35% ～ 70% 左右的数据），其 benchmark 结果如下所示：\n因为大部分时间都在 load 数据，filter 的时间占比很少，所以 parquet 和 orc 的性能更好。\n在 String 类型上进行过滤，并且排除 load 数据的干扰（这个表很小，可以排除掉 load 数据的干扰），其 benchmark 结果如下所示：\nArrow Feather 性能最好，因为没有 decoding。parquet 性能比 orc 好，因为 orc 需要将一批数据 load 到内存，有额外的 string 的 copy。而 parquet 是流式api，可以避免 load 将被过滤掉的数据到内存。\n另外一个实验是测试“过滤谓词的过滤率” 对耗时的影响。论文的步骤是构造一个 bit vector 来表示是否 select 出了数据，然后将列的数据 load 到内存，对数据 apply 这个 bit vector。其 benchmark 结果如下所示：\nArrow 和 ORC 耗时不会随着 selectivity（用来评估多少数据被 select 出来） 的变化而变化，因为都是 load 一批数据到内存，然后 apply bit vector；\n而 Parquet 是流式读取数据，只有apply bit vector 上的才会 decode 数据；所以耗时随着 selectivity 的不同而会有差异；当 selectivity 为 50%，耗时最多，论文的解释是为 50% 的时候，分支预测的错误为最大 “the point at which the largest number of branch mispredictions occur.”\n当 selectivity 较大的时候，ORC 的耗时更少；ORC 耗时更少的原因是 ORC 有专门的内存表示格式， ORC 的批量加载数据的机制更适合高 selectivity 场景。但是当 selectivity 较小的时候，Parquet 的耗时更少，因为 Parquet 需要 decode 更少的数据；\n在 TPC-DS数据集上执行子表达式求值 （Project \u0026amp; Filter）的效率 基于 TPC-DS 数据集和 Query，执行若干联合 Project 和 Filter 的查询语句，其 benchmark 结果如下所示：\nORC 的性能最好：\n加载文件到内存的效率更高\nsmaller row batch 带来更高的 data skipping；但是会带来更多的空间开销\n对于Arrow， load 文件到内存的效率最低；但是当 Arrow 使用压缩的话，效率有所提升，会比 parquet 效率高点；\n列存上的高级优化手段 Arrow 上的优化 Filter 下推到 Encoded data 上 直接在编码后的数据上进行 filter。Arrow 会对 String 类型数据进行 Dict 编码，于是，过滤条件中的 String 常量可以直接 look up 一下这个 Dict，找到被编码后的 Int 值，然后直接用这个 Int 值在编码后的数据上进行过滤。\nGandiva Arrow 社区的一个子项目，是一个 LLVM-based 的 执行 backend，包含向量化等优化\nData skipping 修改 Arrow Feather 的 api 来支持 chunk-level skipping\nParquet 上的优化 避免转成 Arrow 内存格式 以 Parquet 原生的内存表示直接将 Parquet 加载到内存中，而不是再转成 Arrow 的内存格式\n直接在编码后的数据在进行查询，即 Filter 下推到 Encoded data 上\n使用 SIMD 指令来处理\n基于以上的优化，论文分别做了采用不同优化技术的对照实验，结果如下所示：\n其中\nParquet：不使用任何优化，直接使用 Paruqet 的流式 API\nP-ArrowTable：将 parquet 加载成 arrow 格式\nP-IM： Parquet 原生的内存表示\nP-IM+D：P-IM \u0026amp; 支持直接处理编码后的数据\nP-IM+D+SIMD：P-IM+D \u0026amp; 支持 simd 来进行处理\n可以看到使用了这些优化技术后，性能有所提升。\n总结 每种列存格式都有各自的取舍，在不同的工作负载下表现各异，没有一种格式能在所有场景下胜出：\n评估维度 最佳格式 核心优势 压缩率 Parquet 编码与压缩机制最完善，压缩率最高 压缩时间 Arrow Feather 无需编码，压缩速度最快 解压缩时间 Arrow Feather 无需编码，解压速度最快 列裁剪 ORC \u0026amp; Parquet 读取时可直接跳过不需要的列 列谓词过滤 ORC 专有的内存格式使文件加载更高效，适合高选择率的过滤场景 子表达式求值（Project + Filter） ORC 专有的内存格式加载效率高，且更细粒度的 data skipping 进一步减少无效数据读取 因此，构建现代 OLAP 系统需要综合考虑磁盘格式、内存格式和查询引擎三者的协同设计。\nPS：之前听过一个分享说 Parquet 已经成为事实标准，但是我一直很想知道为什么Parquet就成为事实标准，可以一起交流一下。\n","permalink":"https://luoyuxia.github.io/posts/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E5%88%97%E5%AD%98%E6%A0%BC%E5%BC%8Farrowparquetorc/","summary":"本文深入对比Arrow、Parquet、ORC三种列存格式，分析其在压缩、编码、读写性能等方面的差异，总结各自优劣及适用场景。","title":"深入理解列存格式：Arrow，Parquet，ORC"},{"content":"基本语法 变量绑定与解构 声明变量的时候不需要声明类型， Rust 编译器可以根据变量的值和上下文中的使用方式来自动推导出变量的类型，在无法推导出变量类型的时候，就需要手动标注类型，比如 let a: i16 = 1;\n变量绑定\n1 2 3 4 5 6 7 8 9 10 fn main() { // 不可变变量 let a = 1; // 可变变量 let mut x = 5; println!(\u0026#34;The value of x is: {}\u0026#34;, x); x = 6; println!(\u0026#34;The value of x is: {}\u0026#34;, x); } 变量解构\n1 2 3 4 5 6 7 8 fn main() { let (a, mut b): (bool,bool) = (true, false); // a = true,不可变; b = false，可变 println!(\u0026#34;a = {:?}, b = {:?}\u0026#34;, a, b); b = true; assert_eq!(a, b); } 数据类型 基本类型 数值类型：\n字符类型，布尔类型，单元类型（）；\n复合类型 字符串\n字符串\n1 2 3 4 5 6 // 一个 string 类型 let s = String::from(\u0026#34;hello world\u0026#34;); // 一个 字符串切片 let slice = \u0026amp;s[4..len]; let slice = \u0026amp;s[4..]; 元组（Tuple）\nTuple\n1 let tup: (i32, f64, u8) = (500, 6.4, 1); 结构体\nStruct\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct User { active: bool, username: String, email: String, sign_in_count: u64, } let user1 = User { email: String::from(\u0026#34;someone@example.com\u0026#34;), username: String::from(\u0026#34;someusername123\u0026#34;), active: true, sign_in_count: 1, }; let user2 = User { email: String::from(\u0026#34;another@example.com\u0026#34;), ..user1 // 将 user1 的其他字段转移到 user2 中，user1 不能再被使用了，但是user1 email 字段还是可以使用 }; 使用 #[derive(Debug)] 对结构体进行了标记，这样就可以使用 println!(\u0026#34;{:?}\u0026#34;, s); 的方式对其进行打印输出 #[derive(Debug)] struct Rectangle { width: u32, height: u32, } 枚举\nenum\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 enum PokerSuit { Clubs, Spades, Diamonds, Hearts, } let heart = PokerSuit::Hearts; let diamond = PokerSuit::Diamonds; enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn main() { let m1 = Message::Quit; let m2 = Message::Move{x:1,y:1}; let m3 = Message::ChangeColor(255,255,0); } Rust 没有 null 关键字， null 虽然好用，但是我们在使用的时候需要特别小心，不然动不动就 NPE 了。Rust 不引入 null 关键字，对于这种可能为空的情况，使用 Optiion 枚举，类似 Java 里面的 Optional，显式表明这个值可能为空；\nOption\n1 2 3 4 5 6 enum Option\u0026lt;T\u0026gt; { Some(T), None, } let some_number = Some(5); let absent_number: Option\u0026lt;i32\u0026gt; = None; 数组\n1 2 3 4 5 let a = [1, 2, 3, 4, 5]; let a: [i32; 5] = [1, 2, 3, 4, 5]; let first = a[0]; 语句 + 表达式 Rust 的函数体是由一系列语句组成，最后由一个表达式来返回值，\n语句： statement；\n表达式：expression\n1 2 3 4 5 fn add_with_extra(x: i32, y: i32) -\u0026gt; i32 { let x = x + 1; // 语句 let y = y + 5; // 语句 x + y // 表达式，不用加 return } 模式匹配 模式匹配\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 enum Direction { East, West, North, South, } fn main() { let dire = Direction::South; match dire { Direction::East =\u0026gt; println!(\u0026#34;East\u0026#34;), Direction::North | Direction::South =\u0026gt; { println!(\u0026#34;South or North\u0026#34;); }, _ =\u0026gt; println!(\u0026#34;West\u0026#34;), }; } 模式绑定\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #[derive(Debug)] enum UsState { Alabama, Alaska, // --snip-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), // 25美分硬币 } // 捕获 UsState 值 fn value_in_cents(coin: Coin) -\u0026gt; u8 { match coin { Coin::Penny =\u0026gt; 1, Coin::Nickel =\u0026gt; 5, Coin::Dime =\u0026gt; 10, Coin::Quarter(state) =\u0026gt; { println!(\u0026#34;State quarter from {:?}!\u0026#34;, state); 25 }, } } 对于只有一个模式的值需要被处理，其它值直接忽略的场景，可以写成如下：\nif let 匹配\n1 2 3 4 5 let v = Some(3u8); match v { Some(3) =\u0026gt; println!(\u0026#34;three\u0026#34;), _ =\u0026gt; (), } match guard\n1 2 3 4 5 6 7 let num = Some(4); match num { Some(x) if x \u0026lt; 5 =\u0026gt; println!(\u0026#34;less than five: {}\u0026#34;, x), Some(x) =\u0026gt; println!(\u0026#34;{}\u0026#34;, x), None =\u0026gt; (), } 方法 即与对象（rust 中可以是 struct， enum，trait）绑定的函数\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 struct Circle { x: f64, y: f64, radius: f64, } impl Circle { // new是Circle的关联函数，因为它的第一个参数不是self，且new并不是关键字 // 这种方法往往用于初始化当前结构体的实例 fn new(x: f64, y: f64, radius: f64) -\u0026gt; Circle { Circle { x: x, y: y, radius: radius, } } // Circle的方法，\u0026amp;self表示借用当前的Circle结构体 fn area(\u0026amp;self) -\u0026gt; f64 { std::f64::consts::PI * (self.radius * self.radius) } } 内存的摆放：\nself，\u0026amp;self 和 \u0026amp;mut self self 指代的是 Rectangle 结构体实例，self 依然有所有权的概念：\nself 表示 Rectangle 的所有权转移到该方法中，这种形式用的较少\n\u0026amp;self 表示该方法对 Rectangle 的不可变借用\n\u0026amp;mut self 表示可变借用，可以通过 self 来修改struct 中的成员\n为枚举实现方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #![allow(unused)] enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } impl Message { fn call(\u0026amp;self) { // 在这里定义方法体 } } fn main() { let m = Message::Write(String::from(\u0026#34;hello\u0026#34;)); m.call(); } 泛型和特征 泛型 泛型\n1 2 3 4 5 6 7 8 9 fn add\u0026lt;T\u0026gt;(a:T, b:T) -\u0026gt; T { a + b } fn main() { println!(\u0026#34;add i8: {}\u0026#34;, add(2i8, 3i8)); println!(\u0026#34;add i32: {}\u0026#34;, add(20, 30)); println!(\u0026#34;add f64: {}\u0026#34;, add(1.23, 1.23)); } 特征 特征\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // 定义一个 trait pub trait Summary { fn summarize(\u0026amp;self) -\u0026gt; String; } // 定义不同的 trait 对象 pub struct Post { pub title: String, // 标题 pub author: String, // 作者 pub content: String, // 内容 } impl Summary for Post { fn summarize(\u0026amp;self) -\u0026gt; String { format!(\u0026#34;文章{}, 作者是{}\u0026#34;, self.title, self.author) } } pub struct Weibo { pub username: String, pub content: String } impl Summary for Weibo { fn summarize(\u0026amp;self) -\u0026gt; String { format!(\u0026#34;{}发表了微博{}\u0026#34;, self.username, self.content) } } 特征（trait）类似 java 里面的接口，也可以有默认实现。\n特征作为函数参数如下所示：\n1 2 3 pub fn notify(item: \u0026amp;impl Summary) { println!(\u0026#34;Breaking news! {}\u0026#34;, item.summarize()); } 我们还可以约束某个参数实现了多个特征：\n1 2 3 pub fn notify(item: \u0026amp;(impl Summary + Display)) {} pub fn notify\u0026lt;T: Summary + Display\u0026gt;(item: \u0026amp;T) {} 特征对象 如果我们想在一个函数里面返回不同的特征实现的对象，可能会写出类似如下的代码：\n1 2 3 4 5 6 7 8 9 10 11 fn returns_summarizable(switch: bool) -\u0026gt; impl Summary { if switch { Post { // ... } } else { Weibo { // ... } } } 但这样是不行的，因为函数并不支持返回多种不同的类型，在编译的时候就会报错；\nRust 允许我们返回一个特征对象的引用，该引用指向实现了不同特征类型的实例；我们可以直接通过 \u0026amp; 引用，或者 Box 智能指针的方式来引用特征对象。\n返回特征对象\n1 2 3 4 5 6 7 8 9 10 11 fn returns_summarizable(switch: bool) -\u0026gt; Box\u0026lt;dyn Summary\u0026gt; { if switch { Box::new(Post { // ... }) } else { Box::new(Weibo { // ... }) } } 内存布局如下所示：\n集合类型 动态数组 Vector\n1 2 3 4 5 6 7 8 9 10 11 fn main() { let mut v = Vec::with_capacity(10); v.extend([1, 2, 3]); // 附加数据到 v println!(\u0026#34;Vector 长度是: {}, 容量是: {}\u0026#34;, v.len(), v.capacity()); v.reserve(100); // 调整 v 的容量，至少要有 100 的容量 println!(\u0026#34;Vector（reserve） 长度是: {}, 容量是: {}\u0026#34;, v.len(), v.capacity()); v.shrink_to_fit(); // 释放剩余的容量，一般情况下，不会主动去释放容量 println!(\u0026#34;Vector（shrink_to_fit） 长度是: {}, 容量是: {}\u0026#34;, v.len(), v.capacity()); } KV 存储 HashMap\n1 2 3 4 5 6 7 8 9 use std::collections::HashMap; // 创建一个HashMap，用于存储宝石种类和对应的数量 let mut my_gems = HashMap::new(); // 将宝石类型和对应的数量写入表中 my_gems.insert(\u0026#34;红宝石\u0026#34;, 1); my_gems.insert(\u0026#34;蓝宝石\u0026#34;, 2); my_gems.insert(\u0026#34;河边捡的误以为是宝石的破石头\u0026#34;, 18); 错误处理 Rust 中的错误主要分为两类：\n可恢复错误，通常用于从系统全局角度来看可以接受的错误，例如处理用户的访问、操作等错误，这些错误只会影响某个用户自身的操作进程，而不会对系统的全局稳定性产生影响。对应 Rust 的 Result\u0026lt;T, E\u0026gt;\n不可恢复错误，刚好相反，该错误通常是全局性或者系统性的错误，例如数组越界访问，系统启动时发生了影响启动流程的错误等等，这些错误的影响往往对于系统来说是致命的。对应 Rust 的 panic!\n注：java 不会区分这两种错误，统一使用异常（Exception）的方式去处理；\n不可恢复错误 在某些场景，我们可以向主动抛出一个异常，终止程序。Rust 为我们提供了 panic! 宏，当调用执行该宏时，程序会打印出一个错误信息，展开报错点往前的函数调用堆栈，最后退出程序。\npanic\n1 panic!(\u0026#34;crash and burn\u0026#34;); 线程 panic 后，程序是否会终止？\n如果是 main 线程，则程序会终止，如果是其它子线程，该线程会终止，但是不会影响 main 线程。\n何时使用 panic\n可能导致全局有害状态时，比如 非预期的错误，后续代码的运行会受到显著影响，内存安全的问题等。\n当启动时某个流程发生了错误，对后续代码的运行造成了影响，那么就应该使用 panic，而不是处理错误后继续运行，当然你可以通过重试的方式来继续。\n可恢复错误 Rust 有一个统一的类用来表示调用某一个方法，是正常还是出现了异常：\n1 2 3 4 enum。Result\u0026lt;T, E\u0026gt; { Ok(T), // 正常 Err(E), // 异常 } 比如，open 一个文件：\nopen 文件\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 use std::fs::File; use std::io::ErrorKind; fn main() { let f = File::open(\u0026#34;hello.txt\u0026#34;); let f = match f { Ok(file) =\u0026gt; file, Err(error) =\u0026gt; match error.kind() { // 处理异常的类型 ErrorKind::NotFound =\u0026gt; match File::create(\u0026#34;hello.txt\u0026#34;) { Ok(fc) =\u0026gt; fc, Err(e) =\u0026gt; panic!(\u0026#34;Problem creating the file: {:?}\u0026#34;, e), }, other_error =\u0026gt; panic!(\u0026#34;Problem opening the file: {:?}\u0026#34;, other_error), }, }; } 异常传播 程序几乎不太可能只有 A-\u0026gt;B 形式的函数调用，一个设计良好的程序，一个功能涉及十几层的函数调用都有可能。而错误处理也往往不是哪里调用出错，就在哪里处理，实际应用中，大概率会把错误层层上传然后交给调用链的上游函数进行处理，错误传播将极为常见。\n错误传播\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 fn read_username_from_file() -\u0026gt; Result\u0026lt;String, io::Error\u0026gt; { // 打开文件，f是`Result\u0026lt;文件句柄,io::Error\u0026gt;` let f = File::open(\u0026#34;hello.txt\u0026#34;); let mut f = match f { // 打开文件成功，将file句柄赋值给f Ok(file) =\u0026gt; file, // 打开文件失败，将错误返回(向上传播) Err(e) =\u0026gt; return Err(e), }; // 创建动态字符串s let mut s = String::new(); // 从f文件句柄读取数据并写入s中 match f.read_to_string(\u0026amp;mut s) { // 读取成功，返回Ok封装的字符串 Ok(_) =\u0026gt; Ok(s), // 将错误向上传播 Err(e) =\u0026gt; Err(e), } } 用 ？ 简化错误传播\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 fn read_username_from_file() -\u0026gt; Result\u0026lt;String, io::Error\u0026gt; { let mut f = File::open(\u0026#34;hello.txt\u0026#34;)?; let mut s = String::new(); f.read_to_string(\u0026amp;mut s)?; // ? 表示如果有了异常，就直接返回异常，read_to_string 的异常会隐式类型转换 转成 io::Error 异常 Ok(s) } // 链式调用 fn read_username_from_file() -\u0026gt; Result\u0026lt;String, io::Error\u0026gt; { let mut s = String::new(); File::open(\u0026#34;hello.txt\u0026#34;)?.read_to_string(\u0026amp;mut s)?; Ok(s) } 包和模块 Rust 也提供了相应概念用于代码的组织管理，\n项目(Packages)：一个 Cargo 提供的 feature，可以用来构建、测试和分享包\n包(Crate)：一个由多个模块组成的树形结构，可以作为三方库进行分发，也可以生成可执行文件进行运行\n模块(Module)：可以一个文件多个模块，也可以一个文件一个模块，模块可以被认为是真实项目中的代码组织单元\n使用第三方包也很简单，在 cargo.toml 文件中 \\[dependencies\\] 区域中 添加第三方包，然后就可以直接使用了\n1 2 3 4 5 use rand::Rng; fn main() { let secret_number = rand::thread_rng().gen_range(1..101); } https://course.rs/basic/crate-module/intro.html\n高级语法 函数式编程 Rust 支持函数式编程，即\n使用函数作为参数进行传递\n使用函数作为函数返回值\n将函数赋值给变量\n闭包 闭包是一种匿名函数，它可以赋值给变量也可以作为参数传递给其它函数，不同于函数的是，它允许捕获调用者作用域中的值，例如：\n1 2 3 4 5 6 fn main() { let x = 1; let sum = |y| x + y; assert_eq!(3, sum(2)); } 通过闭包，可以写出如下更灵活，更内聚的代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 fn workout(intensity: u32, random_number: u32) { // 无论要修改什么，只要修改闭包 action 的实现即可，其它地方只负责调用； // 不然可能需要修改散落在代码各处的逻辑， let action = || { println!(\u0026#34;muuuu.....\u0026#34;); thread::sleep(Duration::from_secs(2)); intensity }; if intensity \u0026lt; 25 { println!( \u0026#34;今天活力满满，先做 {} 个俯卧撑!\u0026#34;, action() ); println!( \u0026#34;再来 {} 组卧推!\u0026#34;, action() ); } else if random_number == 3 { println!(\u0026#34;昨天练过度了，今天还是休息下吧！\u0026#34;); } else { println!( \u0026#34;昨天练过度了，今天干干有氧，跑步 {} 分钟!\u0026#34;, action() ); } } fn main() { // 动作次数 let intensity = 10; // 随机值用来决定某个选择 let random_number = 7; // 开始健身 workout(intensity, random_number); 闭包对内存的影响 闭包捕获变量有三种途径，恰好对应函数参数的三种传入方式：转移所有权、可变借用、不可变借用，因此相应的 Fn 特征也有三种：\nFnOnce，该类型的闭包会拿走被捕获变量的所有权。Once 顾名思义，说明该闭包只能运行一次，因为第一次调用已经拿走了所有权，接下来的调用尝试拿走所有权就会出错； 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 fn fn_once\u0026lt;F\u0026gt;(func: F) where F: FnOnce(usize) -\u0026gt; bool, { println!(\u0026#34;{}\u0026#34;, func(3)); println!(\u0026#34;{}\u0026#34;, func(4)); } fn main() { let x = vec![1, 2, 3]; fn_once(|z|{z == x.len()}) } // 错误 error[E0382]: use of moved value: `func` --\u0026gt; src\\main.rs:6:20 | 1 | fn fn_once\u0026lt;F\u0026gt;(func: F) | ---- move occurs because `func` has type `F`, which does not implement the `Copy` trait // 因为`func`的类型是没有实现`Copy`特性的 `F`，所以发生了所有权的转移 ... 5 | println!(\u0026#34;{}\u0026#34;, func(3)); | ------- `func` moved due to this call // 转移在这 6 | println!(\u0026#34;{}\u0026#34;, func(4)); | ^^^^ value used here after move // 转移后再次用 仅实现 FnOnce 特征的闭包在调用时会转移所有权，所以显然不能对已失去所有权的闭包变量进行二次调用：\nFnMut，它以可变借用的方式捕获了环境中的值，因此可以修改该值 1 2 3 4 5 6 7 8 9 10 fn main() { let mut s = String::new(); // 注意，不能写成 let update_string = |str| s.push_str(str); let mut update_string = |str| s.push_str(str); update_string(\u0026#34;hello\u0026#34;); println!(\u0026#34;{:?}\u0026#34;,s); } Fn 特征，它以不可变借用的方式捕获环境中的值 1 2 3 4 5 6 7 8 9 fn main() { let s = \u0026#34;hello, \u0026#34;.to_string(); let update_string = |str| println!(\u0026#34;{},{}\u0026#34;,s,str); exec(update_string); println!(\u0026#34;{:?}\u0026#34;,s); } 闭包作为函数返回值 错误版本\n1 2 3 4 5 6 7 8 9 10 fn factory(x:i32) -\u0026gt; impl Fn(i32) -\u0026gt; i32 { let num = 5; if x \u0026gt; 1{ move |x| x + num } else { move |x| x - num } } 正确版本\n1 2 3 4 5 6 7 8 9 fn factory(x:i32) -\u0026gt; Box\u0026lt;dyn Fn(i32) -\u0026gt; i32\u0026gt; { let num = 5; if x \u0026gt; 1{ Box::new(move |x| x + num) } else { Box::new(move |x| x - num) } } 迭代器 iterator 几种 for 迭代的写法\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let arr = [1, 2, 3]; // 写法1 for v in arr { println!(\u0026#34;{}\u0026#34;,v); } // 写法2 for i in 1..10 { println!(\u0026#34;{}\u0026#34;, i); } // 写法3 let arr = [1, 2, 3]; for v in arr.into_iter() { println!(\u0026#34;{}\u0026#34;, v); } 只要实现了 IntoIterator 特征，就可以通过 into_iter 将其转换成迭代器\n除了 into_iter 方法，还有 iter，iter_mut 方法来迭代，三者的区别如下：\ninto_iter 会夺走所有权\niter 是借用\niter_mut 是可变借用\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 fn main() { let values = vec![1, 2, 3]; for v in values.into_iter() { println!(\u0026#34;{}\u0026#34;, v) } // 下面的代码将报错，因为 values 的所有权在上面 `for` 循环中已经被转移走 // println!(\u0026#34;{:?}\u0026#34;,values); let values = vec![1, 2, 3]; let _values_iter = values.iter(); // 不会报错，因为 values_iter 只是借用了 values 中的元素 println!(\u0026#34;{:?}\u0026#34;, values); let mut values = vec![1, 2, 3]; // 对 values 中的元素进行可变借用 let mut values_iter_mut = values.iter_mut(); // 取出第一个元素，并修改为0 if let Some(v) = values_iter_mut.next() { *v = 0; } // 输出[0, 2, 3] println!(\u0026#34;{:?}\u0026#34;, values); } 智能指针 Box Box\u0026lt;T\u0026gt; 允许你将一个值分配到堆上，然后在栈上保留一个智能指针指向堆上的数据。\n使用场景：\n将本应该在栈上的数据（基本类型）存储在堆上；但很少有这种需求，因为一个简单的值分配到堆上并没有太大的意义\n避免栈上数据的拷贝，栈上数据转移所有权时，实际上是把数据拷贝了一份，比如对应数组来说：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 在栈上创建一个长度为1000的数组 let arr = [0;1000]; // 将arr所有权转移arr1，由于 `arr` 分配在栈上，因此这里实际上是直接重新深拷贝了一份数据 let arr1 = arr; // arr 和 arr1 都拥有各自的栈上数组，因此不会报错 println!(\u0026#34;{:?}\u0026#34;, arr.len()); println!(\u0026#34;{:?}\u0026#34;, arr1.len()); // 在堆上创建一个长度为1000的数组，然后使用一个智能指针指向它 let arr = Box::new([0;1000]); // 将堆上数组的所有权转移给 arr1，由于数据在堆上，因此仅仅拷贝了智能指针的结构体，底层数据并没有被拷贝 // 所有权顺利转移给 arr1，arr 不再拥有所有权 let arr1 = arr; println!(\u0026#34;{:?}\u0026#34;, arr1.len()); // 由于 arr 不再拥有底层数组的所有权，因此下面代码将报错 // println!(\u0026#34;{:?}\u0026#34;, arr.len()); 将动态大小类型变为 Sized 固定大小类型 Rust 需要在编译时知道类型占用多少空间，如果一种类型在编译时无法知道具体的大小，那么被称为动态大小类型 DST。\n比如如下代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 enum List { Cons(i32, List), Nil, } // 报错 error[E0072]: recursive type `List` has infinite size //递归类型 `List` 拥有无限长的大小 --\u0026gt; src/main.rs:3:1 | 3 | enum List { | ^^^^^^^^^ recursive type has infinite size 4 | Cons(i32, List), | ---- recursive without indirection // rust 认为 List 是一个 DST 类型，因为它可以无限递归下去， // 但是可以改成 enum List { Cons(i32, Box\u0026lt;List\u0026gt;), // Box\u0026lt;T\u0026gt; 是一个固定size的类型 Nil, } 特征对象 让一个数组包含特征的不同实现 以及函数可以返回特征的不同实现。\n1. 特征对象\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 trait Draw { fn draw(\u0026amp;self); } struct Button { id: u32, } impl Draw for Button { fn draw(\u0026amp;self) { println!(\u0026#34;这是屏幕上第{}号按钮\u0026#34;, self.id) } } struct Select { id: u32, } impl Draw for Select { fn draw(\u0026amp;self) { println!(\u0026#34;这个选择框贼难用{}\u0026#34;, self.id) } } fn main() { let elems: Vec\u0026lt;Box\u0026lt;dyn Draw\u0026gt;\u0026gt; = vec![Box::new(Button { id: 1 }), Box::new(Select { id: 2 })]; for e in elems { e.draw() } } 其实，特征也是 DST 类型，而特征对象在做的就是将 DST 类型转换为固定大小的类型。\nRc 和 Arc Rc（Reference Count） Rc 主要用于同一堆上所分配的数据区域需要有多个只读访问的情况。虽然也可以用多个引用的方式，但是在一些场景中，引用的生命周期也会带来一定的复杂性。因为你可能需要频繁地标注生命周期，比较繁琐。\nRc 的使用如下所示：\nRc 使用示例\n1 2 3 4 5 6 7 8 use std::rc::Rc; fn main() { let a = Rc::new(String::from(\u0026#34;hello, world\u0026#34;)); let b = Rc::clone(\u0026amp;a); assert_eq!(2, Rc::strong_count(\u0026amp;a)); assert_eq!(Rc::strong_count(\u0026amp;a), Rc::strong_count(\u0026amp;b)) } 这里的 clone 仅仅复制了智能指针并增加了引用计数，并没有克隆底层数据，因此 a 和 b 是共享了底层的字符串 s，这种复制效率是非常高的。\n当 a、b 超出作用域后，引用计数会变成 0，最终智能指针和它指向的底层字符串都会被清理释放\nArc 但在多线程下，就无法使用 Rc 了。比如，如下代码将会报错：\n多线程下使用 Rc\n1 2 3 4 5 6 7 8 9 10 11 12 use std::rc::Rc; use std::thread; fn main() { let s = Rc::new(String::from(\u0026#34;多线程漫游者\u0026#34;)); for _ in 0..10 { let s = Rc::clone(\u0026amp;s); let handle = thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, s) }); } } Rc\u0026lt;T\u0026gt; 需要管理引用计数，但是该计数器并没有使用任何并发原语，因此无法实现原子化的计数操作，最终会导致计数错误。\n这个时候可以使用 Arc（Atomic Rc），Arc 可以在多线程的场景下使用，是因为它加了锁，保证引入计数增加的原子性，也因此存在一定的锁的性能损耗。使用如下所示：\nArc 的使用示例\n1 2 3 4 5 6 7 8 9 10 11 12 use std::sync::Arc; use std::thread; fn main() { let s = Arc::new(String::from(\u0026#34;多线程漫游者\u0026#34;)); for _ in 0..10 { let s = Arc::clone(\u0026amp;s); let handle = thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, s) }); } } 总结：\nRc/Arc 是不可变引用，你无法修改它指向的值，只能进行读取。\n一旦最后一个拥有者消失，则资源会自动被回收，这个生命周期是在编译期就确定下来的\nRc 只能用于同一线程内部，Arc 可以用于线程之间的对象共享\nCell 和 RefCell 上面说到的 Rc/Arc 都无法修改内部的值，这个时候就可以使用 Cell 和 RefCell。\nCell 和 RefCell 在功能上没有区别，区别在于 Cell\u0026lt;T\u0026gt; 适用于 T 实现 Copy 的情况；\n可以通过 Cell 来修改一个 \u0026amp;str 类型的值，但不能修改 String 类型的值；因为 \u0026amp;str 实现了 Copy 特征，但是 String 类型没有实现 Copy 特征\nCell\n1 2 3 4 5 6 7 8 use std::cell::Cell; fn main() { let c = Cell::new(\u0026#34;asdf\u0026#34;); let one = c.get(); c.set(\u0026#34;qwer\u0026#34;); let two = c.get(); println!(\u0026#34;{},{}\u0026#34;, one, two); } 将所有权、借用规则与这些智能指针做一个对比：\nRust 规则\n智能指针带来的额外规则\n一个数据只有一个所有者\nRc/Arc让一个数据可以拥有多个所有者\n要么多个不可变借用，要么一个可变借用\nRefCell实现编译期可变、不可变引用共存\n违背规则导致编译错误\n违背规则导致运行时panic\nRefCell 看起来可以解决可变引用和引用可以共存的问题，但是它只是将报错从编译期推迟到运行时，从编译器错误变成了 panic 异常，比如如下代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::cell::RefCell; fn main() { let s = RefCell::new(String::from(\u0026#34;hello, world\u0026#34;)); let s1 = s.borrow(); let s2 = s.borrow_mut(); // 同时存在 s 的一个不可变引用和一个可变引用，违背了 Rust 借用规则， // 虽然在编译期不会报错，但是会在运行期报错 println!(\u0026#34;{},{}\u0026#34;, s1, s2); } // 报错 // thread \u0026#39;main\u0026#39; panicked at \u0026#39;already borrowed: BorrowMutError\u0026#39;, src/main.rs:6:16 // note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace RefCell 简单总结 与 Cell 用于可 Copy 的值不同，RefCell 用于引用\nRefCell 只是将借用规则从编译期推迟到程序运行期，并不能帮你绕过这个规则\nRefCell 适用于编译期误报或者一个引用被在多处代码使用、修改以至于难于管理借用关系时\n使用 RefCell 时，违背借用规则会导致运行期的 panic\nRefCell 适用于编译器误报或者一个引用被在多个代码中使用、修改以至于难于管理借用关系时，还有就是需要内部可变性时。\n一个典型场景是 一个值可以在其方法内部被修改，同时对于其它代码不可变，是很有用的；比如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 定义在外部库中的特征 pub trait Messenger { fn send(\u0026amp;self, msg: String); } // -------------------------- // 我们的代码中的数据结构和实现 struct MsgQueue { msg_cache: Vec\u0026lt;String\u0026gt;, } impl Messenger for MsgQueue { // \u0026amp;self 是不可变引用，但是我也不想修改成 \u0026amp;mut，因为这个接口是别的库定义的 fn send(\u0026amp;self, msg: String) { self.msg_cache.push(msg) // 这里会报错，因为修改了 msg_cache 本身； } } 于是，我们可以修改为：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 use std::cell::RefCell; pub trait Messenger { fn send(\u0026amp;self, msg: String); } pub struct MsgQueue { // msg_cache 本身没变，只是里面包含的这个值变了 msg_cache: RefCell\u0026lt;Vec\u0026lt;String\u0026gt;\u0026gt;, } impl Messenger for MsgQueue { fn send(\u0026amp;self, msg: String) { self.msg_cache.borrow_mut().push(msg) } } fn main() { let mq = MsgQueue { msg_cache: RefCell::new(Vec::new()), }; mq.send(\u0026#34;hello, world\u0026#34;.to_string()); } 多线程 使用多线程 使用多线程\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 use std::thread; use std::time::Duration; fn main() { thread::spawn(|| { for i in 1..10 { println!(\u0026#34;hi number {} from the spawned thread!\u0026#34;, i); thread::sleep(Duration::from_millis(1)); } }); for i in 1..5 { println!(\u0026#34;hi number {} from the main thread!\u0026#34;, i); thread::sleep(Duration::from_millis(1)); } } 通过 move 来将一个值的所有权从一个线程转移到另一个线程\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 use std::thread; // 不 move 值的所有权 fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(|| { // 这里会报错，因为 v 的所有权还是 main 线程的， // Rust 无法确定新的线程会活多久（多个线程的结束顺序并不是固定的），所以也无法确定新线程所引用的 v 是否在使用过程中一直合法： println!(\u0026#34;Here\u0026#39;s a vector: {:?}\u0026#34;, v); }); handle.join().unwrap(); } error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function --\u0026gt; src/main.rs:6:32 | 6 | let handle = thread::spawn(|| { | ^^ may outlive borrowed value `v` 7 | println!(\u0026#34;Here\u0026#39;s a vector: {:?}\u0026#34;, v); | - `v` is borrowed here 需要改成 move v 的所有权的写法：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(move || { println!(\u0026#34;Here\u0026#39;s a vector: {:?}\u0026#34;, v); }); handle.join().unwrap(); // 下面代码会报错borrow of moved value: `v` // println!(\u0026#34;{:?}\u0026#34;,v); } 总结：\nRust 的线程模型是 1:1 模型（每个用户线程正好拥有映射到它的一个内核线程），因为 Rust 要保持尽量小的运行时。\nmain 线程若是结束，则所有子线程都将被终止，如果希望等待子线程结束后，再结束 main 线程，你需要使用创建线程时返回的句柄的 join 方法。\n线程同步 消息传递 通过通道std::sync::mpsc 传递数据\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 use std::sync::mpsc; use std::thread; fn main() { // 创建一个消息通道, 返回一个元组：(发送者，接收者) let (tx, rx) = mpsc::channel(); // 创建线程，并发送消息 thread::spawn(move || { // 发送一个数字1, send方法返回Result\u0026lt;T,E\u0026gt;，通过unwrap进行快速错误处理 tx.send(1).unwrap(); // 下面代码将报错，因为编译器自动推导出通道传递的值是i32类型，那么Option\u0026lt;i32\u0026gt;类型将产生不匹配错误 // tx.send(Some(1)).unwrap() }); // 在主线程中接收子线程发送的消息并输出 println!(\u0026#34;receive {}\u0026#34;, rx.recv().unwrap()); } 注：使用通道来传输数据，一样要遵循 Rust 的所有权规则：\n若值的类型实现了Copy特征，则直接复制一份该值，然后传输过去，例如之前的i32类型\n若值没有实现Copy，则它的所有权会被转移给接收端，在发送端继续使用该值将报错\n如下的代码，违反了规则2，所有编译的时候就会报错\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 use std::sync::mpsc; use std::thread; fn main() { let (tx, rx) = mpsc::channel(); thread::spawn(move || { let s = String::from(\u0026#34;我，飞走咯!\u0026#34;); tx.send(s).unwrap(); //报错，因为 不能再使用 s 了 println!(\u0026#34;val is {}\u0026#34;, s); }); let received = rx.recv().unwrap(); println!(\u0026#34;Got: {}\u0026#34;, received); } 锁、Condvar 和信号量 上面介绍的是使用消息传递来实现同步，还可以使用共享内存来实现同步性，例如通过锁和原子操作等并发原语来实现多个线程同时且安全地去访问一个资源。\n共享内存和消息传递的比较：\n共享内存\n共享内存相对消息传递能节省多次内存拷贝的成本\n共享内存的实现简洁的多\n共享内存的锁竞争更多\n消息传递\n需要可靠和简单的(简单不等于简洁)实现时\n需要模拟现实世界，例如用消息去通知某个目标执行相应的操作时\n需要一个任务处理流水线(管道)时，等等\n互斥锁 Mutex 单线程使用互斥锁 Mutex\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 use std::sync::Mutex; fn main() { // 使用`Mutex`结构体的关联函数创建新的互斥锁实例 let m = Mutex::new(5); { // 获取锁，然后deref为`m`的引用 // lock返回的是Result let mut num = m.lock().unwrap(); *num = 6; // 锁自动被drop } 通过作用域的方式释放锁 println!(\u0026#34;m = {:?}\u0026#34;, m); } 多线程中使用 Mutex\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 use std::sync::{Arc, Mutex}; use std::thread; fn main() { // 需要使用 Arc，而不是 Rc，Rc::clone 是线程不安全的 let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(\u0026amp;counter); let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!(\u0026#34;Result: {}\u0026#34;, *counter.lock().unwrap()); } 用条件变量(Condvar)控制线程的同步 Mutex用于解决资源安全访问的问题，但是我们还需要一个手段来解决资源访问顺序的问题。而 Rust 考虑到了这一点，为我们提供了条件变量(Condition Variables)，它经常和Mutex一起使用，可以让线程挂起，直到某个条件发生后再继续执行\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 use std::sync::{Arc,Mutex,Condvar}; use std::thread::{spawn,sleep}; use std::time::Duration; fn main() { let flag = Arc::new(Mutex::new(false)); let cond = Arc::new(Condvar::new()); let cflag = flag.clone(); let ccond = cond.clone(); let hdl = spawn(move || { let mut lock = cflag.lock().unwrap(); let mut counter = 0; while counter \u0026lt; 3 { while !*lock { // wait方法会接收一个MutexGuard\u0026lt;\u0026#39;a, T\u0026gt;，且它会自动地暂时释放这个锁，使其他线程可以拿到锁并进行数据更新。 // 同时当前线程在此处会被阻塞，直到被其他地方notify后，它会将原本的MutexGuard\u0026lt;\u0026#39;a, T\u0026gt;还给我们，即重新获取到了锁，同时唤醒了此线程。 lock = ccond.wait(lock).unwrap(); } *lock = false; counter += 1; println!(\u0026#34;inner counter: {}\u0026#34;, counter); } }); let mut counter = 0; loop { sleep(Duration::from_millis(1000)); *flag.lock().unwrap() = true; counter += 1; if counter \u0026gt; 3 { break; } 信号量 在多线程中，另一个重要的概念就是信号量，使用它可以让我们精准的控制当前正在运行的任务最大数量。\nSemaphore\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 use std::sync::Arc; use tokio::sync::Semaphore; #[tokio::main] async fn main() { let semaphore = Arc::new(Semaphore::new(3)); let mut join_handles = Vec::new(); for _ in 0..5 { let permit = semaphore.clone().acquire_owned().await.unwrap(); join_handles.push(tokio::spawn(async move { // // 在这里执行任务... // drop(permit); })); } for handle in join_handles { handle.await.unwrap(); } } 上面代码创建了一个容量为 3 的信号量，当正在执行的任务超过 3 时，剩下的任务需要等待正在执行任务完成并减少信号量后到 3 以内时，才能继续执行。\nAtomic 原子类型与内存顺序 原子指的是一系列不可被 CPU 上下文交换的机器指令，这些指令组合在一起就形成了原子操作。在多核 CPU 下，当某个 CPU 核心开始运行原子操作时，会先暂停其它 CPU 内核对内存的操作，以保证原子操作不会被其它 CPU 内核所干扰。\n原子类型是无锁类型，但是无锁不代表无需等待，因为原子类型内部使用了CAS循环，当大量的冲突发生时，该等待还是得等待！但是总归比锁要好。\nAtomic 使用示例\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 use std::ops::Sub; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread::{self, JoinHandle}; use std::time::Instant; const N_TIMES: u64 = 10000000; const N_THREADS: usize = 10; static R: AtomicU64 = AtomicU64::new(0); fn add_n_times(n: u64) -\u0026gt; JoinHandle\u0026lt;()\u0026gt; { thread::spawn(move || { for _ in 0..n { R.fetch_add(1, Ordering::Relaxed); } }) } fn main() { let s = Instant::now(); let mut threads = Vec::with_capacity(N_THREADS); for _ in 0..N_THREADS { threads.push(add_n_times(N_TIMES)); } for thread in threads { thread.join().unwrap(); } assert_eq!(N_TIMES * N_THREADS as u64, R.load(Ordering::Relaxed)); println!(\u0026#34;{:?}\u0026#34;,Instant::now().sub(s)); } 内存顺序 内存顺序是指 CPU 在访问内存时的顺序，该顺序可能受以下因素的影响：\n代码中的先后顺序\n编译器优化导致在编译阶段发生改变(内存重排序 reordering)\n运行阶段因 CPU 的缓存机制导致顺序被打乱\nRust 提供了Ordering::Relaxed用于限定内存顺序了，事实上，该枚举有 5 个成员:\nRelaxed， 这是最宽松的规则，它对编译器和 CPU 不做任何限制，可以乱序\nRelease 释放，设定内存屏障(Memory barrier)，保证它之前的操作永远在它之前，但是它后面的操作可能被重排到它前面\nAcquire 获取, 设定内存屏障，保证在它之后的访问永远在它之后，但是它之前的操作却有可能被重排到它后面，往往和Release在不同线程中联合使用\nAcqRel, 是 Acquire 和 Release 的结合，同时拥有它们俩提供的保证。比如你要对一个 atomic 自增 1，同时希望该操作之前和之后的读取或写入操作不会被重新排序\nSeqCst 顺序一致性， SeqCst就像是AcqRel的加强版，它不管原子操作是属于读取还是写入的操作，只要某个线程有用到SeqCst的原子操作，线程中该SeqCst操作前的数据操作绝对不会被重新排在该SeqCst操作之后，且该SeqCst操作后的数据操作也绝对不会被重新排在SeqCst操作前。\n基于 Send 和 Sync 的线程安全 之前说过 Rc、RefCell 和裸指针不可以在多线程间使用\nRc 在多线程中使用\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 use std::thread; use std::rc::Rc; fn main() { let v = Rc::new(5); let t = thread::spawn(move || { println!(\u0026#34;{}\u0026#34;,v); }); t.join().unwrap(); } error[E0277]: `Rc\u0026lt;i32\u0026gt;` cannot be sent between threads safely ------ 省略部分报错 -------- = help: within `[closure@src/main.rs:5:27: 7:6]`, the trait `Send` is not implemented for `Rc\u0026lt;i32\u0026gt; 看报错是 Rc 没有实现 trait `Send`，那么 trait Send是什么？\n从Rc 和 Arc 的源码看为什么 Rc 不可以在多线程间使用，而 Arc 可以：\n1 2 3 4 5 6 7 // Rc源码片段 impl\u0026lt;T: ?Sized\u0026gt; !marker::Send for Rc\u0026lt;T\u0026gt; {} impl\u0026lt;T: ?Sized\u0026gt; !marker::Sync for Rc\u0026lt;T\u0026gt; {} // Arc源码片段 unsafe impl\u0026lt;T: ?Sized + Sync + Send\u0026gt; Send for Arc\u0026lt;T\u0026gt; {} unsafe impl\u0026lt;T: ?Sized + Sync + Send\u0026gt; Sync for Arc\u0026lt;T\u0026gt; {} 上面代码中Rc\u0026lt;T\u0026gt;的Send和Sync特征被特地移除了实现，而Arc\u0026lt;T\u0026gt;则相反，实现了Sync + Send，再结合之前的编译器报错，大概可以明白了：Send和Sync是在线程间安全使用一个值的关键。\nSend and Sync 实现Send的类型可以在线程间安全的传递其所有权\n实现Sync的类型可以在线程间安全的共享(通过引用)\n如果 T 为 Sync 则 \u0026amp;T 为 Send，如果 \u0026amp;T 为 Send 则 T 为 Sync。\n看一个可以在多线程间使用的 RwLock 的例子，RwLock 的定义如下所示：\n1 unsafe impl\u0026lt;T: ?Sized + Send + Sync\u0026gt; Sync for RwLock\u0026lt;T\u0026gt; {} 首先RwLock可以在线程间安全的共享，那它肯定是实现了Sync。\nRwLock可以并发的读，说明其中的值T必定也可以在线程间共享，那T必定要实现Sync。\n而对于 Mutex 不需要并发地读，T 则不需要实现 Sync，只需要实现 Send 即可以；如果不实现 Send，那么是无法让多个线程访问的；Mutex 代码如下所示：\n1 unsafe impl\u0026lt;T: ?Sized + Send\u0026gt; Sync for Mutex\u0026lt;T\u0026gt; {} 实现Send和Sync的类型 如果我们需要跨多个线程通过引用访问一个值，则需要为这个值 Sync。如果需要跨多个线程转移一个值的所有权，则需要为这个值实现 Send。\n在 Rust 中，几乎所有类型都默认实现了Send和Sync，而且由于这两个特征都是可自动派生的特征(通过derive派生)，意味着一个复合类型(例如结构体), 只要它内部的所有成员都实现了Send或者Sync，那么它就自动实现了Send或Sync。\n裸指针两者都没实现，因为它本身就没有任何安全保证\nUnsafeCell不是Sync，因此Cell和RefCell也不是\nRc两者都没实现(因为内部的引用计数器不是线程安全的)\n如果是自定义的复合类型，那没实现那哥俩的就较为常见了：只要复合类型中有一个成员不是Send或Sync，那么该复合类型也就不是Send或Sync。\n手动实现 Send 和 Sync 是不安全的，通常并不需要手动实现 Send 和 Sync trait，实现者需要使用unsafe小心维护并发安全保证。\n为裸指针实现 Send 和 Sync 特征 实现 Send\n实现 Send\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 use std::thread; #[derive(Debug)] struct MyBox(*mut u8); unsafe impl Send for MyBox {} fn main() { // 这里是直接使用 p，而不是 \u0026amp;p let p = MyBox(5 as *mut u8); let t = thread::spawn(move || { println!(\u0026#34;{:?}\u0026#34;,p); }); t.join().unwrap(); } 实现 Sync\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use std::thread; use std::sync::Arc; use std::sync::Mutex; #[derive(Debug)] struct MyBox(*const u8); unsafe impl Sync for MyBox {} fn main() { let b = \u0026amp;MyBox(5 as *const u8); let v = Arc::new(Mutex::new(b)); let t = thread::spawn(move || { let _v1 = v.lock().unwrap(); }); t.join().unwrap(); } 其实 ，这样只是取悦编译器，告诉编译器我确保这个类型是 Send \u0026amp; Sync 的；如果不是的话，在运行的时候可能会出现 panic 或者未定义行为；\n总结：\n实现Send的类型可以在线程间安全的传递其所有权, 实现Sync的类型可以在线程间安全的共享(通过引用)\n可以为自定义类型实现Send和Sync，但是需要unsafe代码块\n可以为部分 Rust 中的类型实现Send、Sync，但是需要使用newtype，例如文中的裸指针例子\nMacro 宏编程 宏是通过一种代码来生成另一种代码，宏可以帮我们减少所需编写的代码，也可以一定程度上减少维护的成本，虽然函数复用也有类似的作用，但是宏依然拥有自己独特的优势。\n比如 Rust 的函数签名是固定的：定义了两个参数，就必须传入两个参数，多一个少一个都不行。\n而宏就可以拥有可变数量的参数，例如可以调用一个参数的 println!(\u0026quot;hello\u0026quot;)，也可以调用两个参数的 println!(\u0026quot;hello {}\u0026quot;, name)。\n由于宏会被展开成其它代码，且这个展开过程是发生在编译器对代码进行解释之前。因此，宏可以为指定的类型实现某个特征：先将宏展开成实现特征的代码后，再被编译。\n而函数就做不到这一点，因为它直到运行时才能被调用，而特征需要在编译期被实现。\n宏是将一个值跟对应的模式进行匹配，且该模式会与特定的代码相关联。宏里的值是一段 Rust 源代码(字面量)，模式用于跟这段源代码的结构相比较，一旦匹配，传入宏的那段源代码将被模式关联的代码所替换，最终实现宏展开。值得注意的是，所有的这些都是在编译期发生，并没有运行期的性能损耗。\n比如 如下的 vec! 宏，可以方便地来初始化一个数组，并且支持任何元素类型，也并没有限制数组的长度，如果使用函数，我们是无法做到这一点的。\n1 let v: Vec\u0026lt;u32\u0026gt; = vec![1, 2, 3]; vec! 也是用宏实现的，其宏的代码如下所示：\n1 2 3 4 5 6 7 8 9 10 11 12 #[macro_export] macro_rules! vec { ( $( $x:expr ),* ) =\u0026gt; { { let mut temp_vec = Vec::new(); $( temp_vec.push($x); )* temp_vec } }; } ","permalink":"https://luoyuxia.github.io/posts/rust%E8%AF%AD%E6%B3%95%E6%89%8B%E5%86%8C/","summary":"本文系统介绍了Rust语言的核心语法，涵盖变量、数据类型、函数、模式匹配、错误处理、泛型、并发编程及宏等关键特性。","title":"Rust：语法手册"},{"content":"内存管理 程序在运行过程中申请了内存，要能够释放掉不需要的内存，否则程序将会耗尽计算机的内存。\n其他语言 程序员手动释放 代表语言：C，C++；\n由程序员自己写代码来释放掉不需要的内存，通过函数调用的方式来申请和释放内存\nC++\n1 2 3 4 5 ObjecT* obj = new ObjecT(); // do some thing; ... // no need any more, release the momory obj occupy delete obj; 程序员自己知道哪些对象不再被需要，理解上是可以做到非常准确的内存管理的。但是会存在程序员忘了释放对象的情况，导致内存泄漏。\n语言的 runtime 自动释放 代表语言：Java，Go\n在程序运行过程中，语言的 runtime 会不断地寻找不再被使用的内存，然后自动释放掉，不需要程序员自己写代码去释放；简单，但是会存在 stop the world 的问题。\nRust 内存管理核心就是 释放掉不会再被使用的内存。而 Rust 可以在编译的时候，通过静态分析的方法知道什么时候可以安全地释放掉这块内存；\n这块内存对应的就是在这块内存中存放的对象，或者说 值；\n注：Rust 用 值 这个概念来表示，以后我们也统一用 值 来表示；\nRust 语言内存管理 Rust 如何释放内存，简单总结思路就是，当一个值（指向该值的变量）离开作用域后，这个值就可以被释放掉；其实和 C++ 的智能指针很像；\n如下例子：\n1 2 3 4 { // s 在这里无效，它尚未声明 let s = \u0026#34;hello\u0026#34;; // 从此处起，s 是有效的 // 使用 s } // 此作用域已结束，s不再有效 当离开了作用域的时候，s 就可以被 drop 掉了\n所有权 但是如果只是简单地判断某个值对应的变量离开了作用域，就 drop 掉的话，就会存在两次 drop 同一块内存，比如，如果 s1 和 s2 都 指向同一块内存，当 s1 和 s2 都离开作用域了，就会释放相同的内存两次。\n所以，rust 保证同一块内存只能有一个所有者， 这样保证了只有所有者离开了作用域，才会 drop 掉这块内存；\n即 当 s1 被赋予 s2 后，Rust 认为 s1 不再有效，因此也无需在 s1 离开作用域后 drop 任何东西，这就是把所有权从 s1 转移给了 s2，s1 在被赋予 s2 后就马上失效了。当 s2 离开作用域后才会释放这块内存。\n于是，如下的代码就会报错：\n1 2 3 4 let s1 = String::from(\u0026#34;hello\u0026#34;); let s2 = s1; // s1 拥有的 \u0026#34;hello\u0026#34; 被转移到 s2 了， println!(\u0026#34;{}, world!\u0026#34;, s1); // s1不再拥有 \u0026#34;hello\u0026#34;了，再使用 s1 就会有问题； let s2 = s1 发生的所有权转移如下图所示，不做数据的 copy，只是在栈上创建了一个新的变量，指向该值；可以理解为浅拷贝。\n注意：并不是 s2 = s1 这种写法都等于 浅拷贝，对于rust 内置的基础类型这种 栈中存储的类型，比如 bool，u32 等，这种在 s2 = s1 对应的还是深拷贝。\n总结一下就是：\n一个值只能被一个变量所拥有，或者说一个值只能拥有一个所有者\n当所有者(变量)离开作用域范围时，这个值将被丢弃(drop)\n引用和借用 如果只有通过获得所有权的方式来获得一个值，程序就会变得复杂，特别是在函数调用的时候，\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 fn main() { let s2 = String::from(\u0026#34;hello\u0026#34;); // s2 进入作用域 let s3 = takes_and_gives_back(s2); // s2 被移动到 // takes_and_gives_back 中, // 它也将返回值移给 s3 } // 这里, s3 移出作用域并被丢弃。s2 也移出作用域，但已被移走， // 所以什么也不会发生。s1 移出作用域并被丢弃 // takes_and_gives_back 将传入字符串并返回该值 fn takes_and_gives_back(a_string: String) -\u0026gt; String { // a_string 进入作用域 a_string // 返回 a_string 并移出给调用的函数 } 如果在 give_ownership 这个函数内不把传进来的 a_string 传回去，那么 takes_and_gives_back 函数调用结束后， a_string 对应的值就会被释放掉。\n于是Rust 允许使用某个变量的引用来访问该值，避免了不必要的所有权转移\n这个获取变量的引用的过程，称为借用（borrowing）\n比如 下面的代码，我们用 s1 的引用作为参数传递给 calculate_length 函数，而不是把 s1 的所有权转移给该函数：\n1 2 3 4 5 6 7 8 9 10 11 fn main() { let s1 = String::from(\u0026#34;hello\u0026#34;); let len = calculate_length(\u0026amp;s1); println!(\u0026#34;The length of \u0026#39;{}\u0026#39; is {}.\u0026#34;, s1, len); } fn calculate_length(s: \u0026amp;String) -\u0026gt; usize { s.len() } \u0026amp;s1 可以理解为一个指向 s1 这个变量的 变量，并不拥有这个\u0026quot;hello\u0026quot;值本身，如下图所示：\n但是通过 \u0026amp;s1 没法对这个值进行修改，比如：\n1 2 3 4 5 6 7 8 9 fn main() { let s = String::from(\u0026#34;hello\u0026#34;); change(\u0026amp;s); } fn change(some_string: \u0026amp;String) { some_string.push_str(\u0026#34;, world\u0026#34;); // 会报错 } 需要可变引用才行，\u0026amp;mut s\n1 2 3 4 5 6 7 8 9 fn main() { let mut s = String::from(\u0026#34;hello\u0026#34;); change(\u0026amp;mut s); } fn change(some_string: \u0026amp;mut String) { some_string.push_str(\u0026#34;, world\u0026#34;); } 但是使用可变引用，有如下限制：\n同一个作用域内，一个值只能由一个可变引用： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 let mut s = String::from(\u0026#34;hello\u0026#34;); let r1 = \u0026amp;mut s; let r2 = \u0026amp;mut s; println!(\u0026#34;{}, {}\u0026#34;, r1, r2); // 报错 error[E0499]: cannot borrow `s` as mutable more than once at a time 同一时间无法对 `s` 进行两次可变借用 --\u0026gt; src/main.rs:5:14 | 4 | let r1 = \u0026amp;mut s; | ------ first mutable borrow occurs here 首个可变引用在这里借用 5 | let r2 = \u0026amp;mut s; | ^^^^^^ second mutable borrow occurs here 第二个可变引用在这里借用 6 | 7 | println!(\u0026#34;{}, {}\u0026#34;, r1, r2); | -- first borrow later used here 第一个借用在这里使用 这种限制的好处就是使 Rust 在编译期就避免数据竞争，因为现在只有一个可变引用可以被用来修改一个值；\n可变引用与不可变引用不能同时存在 这也是为了避免数据竞争，防止一个不可变引用在使用过程中被其他人（引用）修改。\n对于使用引用来说，还有一个非常重要的限制：\n引用不能引用一个无效（被释放掉）的值，比如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 fn main() { let reference_to_nothing = dangle(); } fn dangle() -\u0026gt; \u0026amp;String { let s = String::from(\u0026#34;hello\u0026#34;); // s 会在函数调用结束后被释放，返回一个被释放的值的引用是无效的 \u0026amp;s } // 报错 error[E0106]: missing lifetime specifier --\u0026gt; src/main.rs:5:16 | 5 | fn dangle() -\u0026gt; \u0026amp;String { | ^ expected named lifetime parameter | = help: this function\u0026#39;s return type contains a borrowed value, but there is no value for it to be borrowed from help: consider using the `\u0026#39;static` lifetime | 5 | fn dangle() -\u0026gt; \u0026amp;\u0026#39;static String { | ~~~~~~~~ 生命周期（lifetime） 上面提到，引用不能引用一个被释放掉的值，但是值的释放是和值的 拥有者 有关的，只要值的 拥有者 离开作用域了，值就会被释放，那么这个时候 引用 不就引用了一个无效的值吗？\n于是 Rust 提出了生命周期 和 借用检查器(Borrow checker) 来检查我们的 借用是否合法，简而言之就是如果一个变量引用了一个作用域（life time）更小的值，编译的时候就会报错。\n比如如下代码就会报错：\n1 2 3 4 5 6 7 8 9 10 { let r; // ---------+-- \u0026#39;a // | { // | let x = 5; // -+-- \u0026#39;b | r = \u0026amp;x; // | | } // -+ | // | println!(\u0026#34;r: {}\u0026#34;, r); // | } 编译器发现 r 明明拥有生命周期 'a，但是却引用了一个小得多的生命周期 'b，在这种情况下，编译器会认为我们的程序存在风险，因此拒绝运行。\n函数中的生命周期 大部分时间，编译器会自动帮我们推断出变量的生命周期（只有一个参数的话，直接用这个参数的生命周期作为返回值的生命周期），但是依然有例外：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 fn main() { let string1 = String::from(\u0026#34;abcd\u0026#34;); let string2 = \u0026#34;xyz\u0026#34;; let result = longest(string1.as_str(), string2); println!(\u0026#34;The longest string is {}\u0026#34;, result); } fn longest(x: \u0026amp;str, y: \u0026amp;str) -\u0026gt; \u0026amp;str { if x.len() \u0026gt; y.len() { x } else { y } } // 报错 error[E0106]: missing lifetime specifier --\u0026gt; src/main.rs:9:33 | 9 | fn longest(x: \u0026amp;str, y: \u0026amp;str) -\u0026gt; \u0026amp;str { | ---- ---- ^ expected named lifetime parameter // 参数需要一个生命周期 | = help: this function\u0026#39;s return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y` = 帮助： 该函数的返回值是一个引用类型，但是函数签名无法说明，该引用是借用自 `x` 还是 `y` help: consider introducing a named lifetime parameter // 考虑引入一个生命周期 | 9 | fn longest\u0026lt;\u0026#39;a\u0026gt;(x: \u0026amp;\u0026#39;a str, y: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str { | ^^^^ ^^^^^^^ ^^^^^^^ ^^^ Rust 无法知道返回函数 longest 返回的 \u0026amp;str 的生命周期是哪个，但是编译器需要知道这些，来确保函数调用后的引用生命周期分析。\n此时就需要我们手动去标注，通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。\n生命周期标注语法 标记的生命周期只是为了取悦编译器，让编译器不要难为我们，并不会改变任何引用的实际作用域。\n标注语法\n1 2 3 \u0026amp;i32 // 一个引用 \u0026amp;\u0026#39;a i32 // 具有显式生命周期的引用 \u0026amp;\u0026#39;a mut i32 // 具有显式生命周期的可变引用 对于之前的例子，我们可以通过如下的生命周期标注来解决：\n1 2 3 4 5 6 7 fn longest\u0026lt;\u0026#39;a\u0026gt;(x: \u0026amp;\u0026#39;a str, y: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str { if x.len() \u0026gt; y.len() { x } else { y } } 在通过函数签名指定生命周期参数时，我们并没有改变传入引用或者返回引用的真实生命周期，而是告诉编译器当不满足此约束条件时，就拒绝编译通过。比如如果其他变量使用了这个函数的返回值 ，但是这个变量的生命周期大于我们标注的这个函数的返回值的生命周期，就会拒绝编译。\n当把具体的引用传给 longest 时，那生命周期 'a 的大小就是 x 和 y 的作用域的重合部分，换句话说，'a 的大小将等于 x 和 y 中较小的那个。由于返回值的生命周期也被标记为 'a，因此返回值的生命周期也是 x 和 y 中作用域较小的那个。\n比如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 fn main() { let string1 = String::from(\u0026#34;long string is long\u0026#34;); let result; { let string2 = String::from(\u0026#34;xyz\u0026#34;); result = longest(string1.as_str(), string2.as_str()); } // 虽然我们自己清楚地知道 result 是引用 string1 的，生命周期不冲突， // 但是编译器并不知道 println!(\u0026#34;The longest string is {}\u0026#34;, result); } // 报错： error[E0597]: `string2` does not live long enough --\u0026gt; src/main.rs:6:44 | 6 | result = longest(string1.as_str(), string2.as_str()); | ^^^^^^^ borrowed value does not live long enough 7 | } | - `string2` dropped here while still borrowed 8 | println!(\u0026#34;The longest string is {}\u0026#34;, result); | ------ borrow later used here 编译器认为返回函数 longest 的返回的引用 result 的生命周期是 string1 和 string2 更小的那个，最小的是 string2， 其生命周期是 5 ～ 6 行代码。于是编译器认为 result 的生命周期是 5 ～ 6 行代码。 但是在第10 行还在使用 result，于是就直接报错了。\n总结一下关于函数的返回值：\n函数的返回值如果是一个引用类型，那么它的生命周期只会来源于：\n函数参数的生命周期\n函数体中某个新建引用的生命周期\n但是如果 返回值是来自 “函数体中某个新建引用的生命周期” 的话，会导致悬垂引用，Rust 会拒绝编译。所以实际上，对 Rust 来说，返回值的生命周期只会来源函数参数的生命周期。\n结构体中的生命周期 一个结构体中也可能引用一个值，在结构体引用值，只要为结构体中的每一个引用标注上生命周期即可：\n1 2 3 4 5 6 7 8 9 10 11 struct ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { part: \u0026amp;\u0026#39;a str, } fn main() { let novel = String::from(\u0026#34;Call me Ishmael. Some years ago...\u0026#34;); let first_sentence = novel.split(\u0026#39;.\u0026#39;).next().expect(\u0026#34;Could not find a \u0026#39;.\u0026#39;\u0026#34;); let i = ImportantExcerpt { part: first_sentence, }; } 该生命周期标注说明，结构体 ImportantExcerpt 所引用的字符串 str 生命周期需要大于等于该结构体的生命周期。\n上述述代码满足生命周期要求，可以通过编译，但是如下代码就无法通过编译：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #[derive(Debug)] struct ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { part: \u0026amp;\u0026#39;a str, } fn main() { let i; { let novel = String::from(\u0026#34;Call me Ishmael. Some years ago...\u0026#34;); let first_sentence = novel.split(\u0026#39;.\u0026#39;).next().expect(\u0026#34;Could not find a \u0026#39;.\u0026#39;\u0026#34;); i = ImportantExcerpt { part: first_sentence, }; // i 的生命周期 大于 first_sentence 生命周期， } println!(\u0026#34;{:?}\u0026#34;,i); } // 出错 error[E0597]: `novel` does not live long enough --\u0026gt; src/main.rs:10:30 | 10 | let first_sentence = novel.split(\u0026#39;.\u0026#39;).next().expect(\u0026#34;Could not find a \u0026#39;.\u0026#39;\u0026#34;); | ^^^^^^^^^^^^^^^^ borrowed value does not live long enough ... 14 | } | - `novel` dropped here while still borrowed 15 | println!(\u0026#34;{:?}\u0026#34;,i); | - borrow later used here 生命周期消除 在很多情况下，我们并不需要手动标注生命周期，比如如下的代码：\n1 2 3 4 5 6 7 8 9 10 11 fn first_word(s: \u0026amp;str) -\u0026gt; \u0026amp;str { let bytes = s.as_bytes(); for (i, \u0026amp;item) in bytes.iter().enumerate() { if item == b\u0026#39; \u0026#39; { return \u0026amp;s[0..i]; } } \u0026amp;s[..] } 这是因为编译器为了简化用户的使用，运用了生命周期消除大法。\n三条消除规则 每一个引用参数都会获得独自的生命周期\n若只有一个输入生命周期(函数参数中只有一个引用类型)，那么该生命周期会被赋给所有的输出生命周期\n若存在多个输入生命周期，且其中一个是 \u0026amp;self 或 \u0026amp;mut self，则 \u0026amp;self 的生命周期被赋给所有的输出生命周期\n拥有 \u0026amp;self 形式的参数，说明该函数是一个 方法，该规则让方法的使用便利度大幅提升。 规则应用案例：\n函数的规则： 1 2 3 4 5 6 7 fn first_word(s: \u0026amp;str) -\u0026gt; \u0026amp;str { // 实际项目中的手写代码 -\u0026gt; fn first_word\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;str { // 编译器自动为参数添加生命周期 -\u0026gt; fn first_word\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str { // 编译器自动为返回值添加生命周期 结构体方法中的规则： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 impl\u0026lt;\u0026#39;a\u0026gt; ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { fn announce_and_return_part(\u0026amp;self, announcement: \u0026amp;str) -\u0026gt; \u0026amp;str { println!(\u0026#34;Attention please: {}\u0026#34;, announcement); self.part } } -\u0026gt; 每个输入参数一个生命周期 impl\u0026lt;\u0026#39;a\u0026gt; ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { fn announce_and_return_part\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;a self, announcement: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;str { println!(\u0026#34;Attention please: {}\u0026#34;, announcement); self.part } } -\u0026gt; 将 \u0026amp;self 的生命周期赋给返回值 \u0026amp;str impl\u0026lt;\u0026#39;a\u0026gt; ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { fn announce_and_return_part\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;a self, announcement: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;a str { println!(\u0026#34;Attention please: {}\u0026#34;, announcement); self.part } } 如果我们手动将返回的引用生命周期改为 \u0026lsquo;b 呢\n1 2 3 4 5 6 impl\u0026lt;\u0026#39;a\u0026gt; ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { fn announce_and_return_part\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;a self, announcement: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;b str { println!(\u0026#34;Attention please: {}\u0026#34;, announcement); self.part } } 编译器会报错，因为编译器无法知道 'a 和 'b 的关系，这个方法返回的 self.part的生命周期大于等于 'a，但是它不知道和'b之间的关系，编译器无法保证 self.part在'b生命周期始终有效。\n有一点很容易推理出来：由于 \u0026amp;'a self 是被引用的一方，因此引用它的 \u0026amp;'b str 必须要活得比它短，否则会出现悬垂引用，即保证在'b生命周期内，self.part始终有效，所以只需要标注 'b生命周期小于等于'a即可。\n通过如下的标注语法可解决：\n1 2 3 4 5 6 7 impl\u0026lt;\u0026#39;a: \u0026#39;b, \u0026#39;b\u0026gt; ImportantExcerpt\u0026lt;\u0026#39;a\u0026gt; { fn announce_and_return_part(\u0026amp;\u0026#39;a self, announcement: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;b str { println!(\u0026#34;Attention please: {}\u0026#34;, announcement); self.part } } // \u0026#39;a: \u0026#39;b 标注 \u0026#39;a 必须比 \u0026#39;b 活得久 ","permalink":"https://luoyuxia.github.io/posts/rust%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86---no-gc-%E7%9A%84%E9%AD%94%E6%B3%95/","summary":"Rust通过所有权、借用和生命周期机制在编译时确保内存安全，无需垃圾回收。","title":"Rust：内存管理 - No GC 的魔法"},{"content":"去年图灵奖得主 Michael Stonebraker 和 CMU 知名教授 Andrew Pavlo 写了一篇论文 What Goes Around Comes Around\u0026hellip; And Around\u0026hellip; 回顾了过去20年数据库领域的发展。\n这篇论文的标题挺有意思的，我将这篇论文的标题理解为 数据库技术总归都周而复始的，螺旋上升，兜兜转转又回到过去的技术路线，以新的形式焕发生命力。\n不过通读全文，我饿觉得还有另外一种理解是，“Make 关系型数据库 Great Again”。\n论文中分析了数据库近 20 年的发展，分别从数据模型\u0026amp;查询语言（Data Models \u0026amp; Query Languages)，以及系统架构（System Architectures) 两部分入手进行分析。\n数据模型\u0026amp;查询语言 MapReduce System MapReduce 已死，被后来的系统 Spark/Flink 所替代 。HDFS 苟延残喘，企业逐渐意识到有很好的分布式存储的替代品，如 S3 这种对象存储。但是论文还是高度肯定了 MapReduce 的价值，MapReduce 推动了共享磁盘架构的复兴，开源文件格式和数据湖的涌现。\nKey/Value 系统 KV 系统只适用于简单的应用，不适用于存储包含多个 field 的 record，因为需要解析 record，也没有对 value field 的二级索引。\n已有的 RDBMS 可以很容易地模拟 KV store；另外一种架构是让 KV store 作为 DBMS 的底层存储引擎，基于 KV store 可以更快地实现一个 DBMS，比如 TIDB。\n文档数据库 NoSQL文档存储一开始是因为它提供非规范化数据结构、低级API和水平可扩展性而受到开发者欢迎。虽然它们出现的时候宣称自己的事 NoSQL 数据库，但是它们正在与关系数据库管理系统（RDBMS）发生重合，为文档数据库添加 SQL 和ACID 的支持等；RDBMS与专门的文档数据库的区别也越来越小。\n列族数据库 BigTable，Cassandra，HBase 等，但它们最终都提供对 SQL 的支持；已经过时，很小众的市场\n文本搜索引擎 虽然有专门的文本搜索引擎（如Elasticsearch），但是现在 RDBMS 也可以进行文本搜索，这样就可以避免用户维护两套不同的系统，Elasticsearch 做文本搜索，RDBMS 处理业务数据，并且还有一条 ETL pipeline 将 RDBMS 的数据转到 Elasticsearch 中。\n数组数据库 数组数据库（如Rasdaman、kdb+和SciDB）在科学界很受欢迎，因为关系数据库管理系统不能有效地存储和分析数组\n向量数据库 向量数据库（如Pineone、Milvus 和 Weaviate）是专门用于存储向量的数据库；作者认为向量数据库最终也会添加对 SQL 的支持；\n但是随着 LLM 的流行，几乎所有的 RDMS vendor 都提供了对向量的支持；\n虽然向量数据库与AI工具的集成更好，但作者认为它们的长期可行性不佳，因为关系数据库可能会采用它们的所有特性。\n图数据库 RDBMS 也可以用一堆表来模拟 graph，SQL:2023 也引入了图上的查询语法。这都使得图数据库和 RDBMS 之间的区分度也更小；而且最近也有工作表明，用 DuckDB 模拟图数据库，并且执行图上的 SQL，性能要10x好于专门的图数据库。\n系统架构（System Architectures) 列存系统 压缩率更高\n可以支持向量化执行\n对于行存，每条 record 行存有很大的 header 的开销，比如 20bytes 来trace null 值，版本信息。每个 record 都会有，但是对于列存，每列一个 header 来进行记录，空间开销远小于行存。\n由于它们的“卓越性能”，已经主导数据仓库/OLAP市场\n云数据库 云数据库具有 per-query 的弹性，存算分离的架构。但是存算分离的架构其实也是之前 mutil node shared-disk 的 DBMS 的 idea，但是随着技术的变化，更快的网络和硬件，企业向云迁移的趋势，重新变得流行起来了。\n从商业的角度看，开源 DBMS 也要警惕被云厂商白嫖\n数据湖/ LakeHouse 数据湖对查询优化带来了挑战，因为数据湖对新 ingest 的数据缺少统计信息。\n目前所有的主流云 vendor 都提供了 managed data lake service。由于数据湖基于对象存储，比传统的 OLAP 系统便宜，传统OLAP供应商（例如Teradata、Vertica）扩展其DBMS以支持从对象存储读取数据以应对这种竞争。\n作者认为数据湖将是未来十年OLAP DBMS的原型。\nNewSQL NewSQL 数据库尝试在不放弃 ACID 保证的前提下，像 NoSQL 数据库一样水平扩展，即分布式关系型数据库，类似 TIDB；但是并没有像列存数据库和云数据库那样流行起来；\n硬件加速 目前只有大型的云 vendor 会定制化硬件来进行加速；如 AWS Redshift 的 AQUA 等；但可以认为未来会有更多这样的尝试。\n区块链数据库 对上层的应用来说，非常低效。已成为一种逐渐消退的数据库技术趋势。\n结语 论文作者最后分享了几个对过去二十年数据库的分析有几个要点，不幸的是，部分要点只是对 20 年前论文给出的提醒的重复。\n这也算是点题了吧\n不要低估劣质数据库系统的营销 过去，劣质的数据库系统凭借自己过人的营销，取得了很大的成功，尽管当时有更好的选择。比如 1980 年代的 Oracle，2000 年代的 MySQL，2010 年代的 MongoDB。这些系统在早期获得了足够的生存空间，为他们赢得了时间来修复系统本身的缺陷。\n我只负责翻译，上述观点仅代表作者本人意见。\n警惕非专业数据库系统公司开发的数据库系统 过去二十年有个比较有趣的现象是，大型科技公司在内部构建自己的数据库系统，然后将其开源出来，通常是捐赠到 Apache 社区，希望获得外部贡献者的免费开发。比如 Meta 的 Hive，RocksDB，LinkIn 的 Kafka，\n这种内部造轮子的趋势部分原因是很多公司的晋升机制更青睐那些创建新内部系统的工程师，即使已有现成工具足够好。然而，这种倾向导致很多缺乏 DBMS 工程经验的团队也去尝试开发新的系统。对于此类公司首次开源的系统，人们应保持谨慎，因为它们几乎总是尚不成熟的技术。\n总之就是：别来沾边。\n虽然我认同部分观点，但是感觉作者有点偏激了。\n不要忽视开箱即用的体验 许多非关系型DBMS的一个突出卖点是比RDBMS更好的\u0026quot;开箱即用\u0026quot;体验。大多数SQL系统需要首先创建数据库，然后定义表，然后才能加载数据。这就是为什么数据科学家使用Python笔记本快速分析数据文件的原因。因此，每个DBMS都应该让本地和云存储文件的就地处理变得容易。DuckDB日益流行，部分原因是它在这方面做得很好。\n开发者需要直接查询他们的数据库 虽然过去20年创建的大多数OLTP应用程序主要通过抽象层与数据库交互，例如端点API（如REST、GraphQL）或对象关系映射器（ORM）库。这些层将应用程序的高级请求转换为数据库查询。但是可以通过 SQL 直接查询数据库还是非常重要的。\nAI/ML 对数据库系统的影响是非常大的 DBMS应该如何与现代AI/ML工具交互最近已成为一个关键问题。\nLLM在将自然语言（NL）转换为查询代码（如SQL）方面有很大的进步，但是并不认为自然语言将取代 SQL，没有人会使用NL编写OLTP应用程序，因为大多数使用ORM生成查询。\n对于OLAP数据库，NL在构建探索性分析的初始查询方面可能很有帮助。然而，这些查询应该暴露给类似仪表板的细化工具，因为英语和其他语言充满歧义和不精确性。\n企业内部对依赖当前LLM技术进行决策存在不情愿，特别是涉及财务数据时。最大的问题是LLM的输出对人类来说是不可解释的，并且企业并不想把训练模型的数据直接给到非专业人员。由于这些原因，LLM 在企业数据中的采用将谨慎和缓慢。\n虽然最近有大量关于使用AI/ML优化DBMS的研究，包括面向 ML 的查询优化器，尽管这种ML辅助优化是提高DBMS性能的强大工具，但它并不能消除对高质量系统工程的需求\n总结 尽管关系模型和 SQL 可能面临新的挑战，但它们不太可能被新的数据模型所取代。\n作者鼓励数据库社区“促进开源可重用组件和服务的发展”，有一些朝着这个目标的努力，包括文件格式（Iceberg, Hudi, Delta）、查询优化（例如 Calcite, Orca）和执行引擎（例如 ataFusion, Velox），数据库社区应该努力实现一个类似POSIX的标准，以加速互操作性。\n","permalink":"https://luoyuxia.github.io/posts/%E4%B8%80%E7%AF%87%E8%AE%BA%E6%96%87%E5%B8%A6%E4%BD%A0%E5%9B%9E%E9%A1%BE%E6%95%B0%E6%8D%AE%E5%BA%93%E8%BF%87%E5%8E%BB-20-%E5%B9%B4%E7%9A%84%E5%8F%91%E5%B1%95/","summary":"该论文回顾数据库20年发展，指出技术螺旋演进，关系模型与SQL仍占主导，新兴系统多被其吸收融合，强调开源组件与标准的重要性。","title":"一篇论文带你回顾数据库过去 20 年的发展"},{"content":"RedPanda 的官方评测 RedPanda 的 Benchmark 结果 RedPanda 宣称自己兼容 Kafka 协议的，用 C++ 写的，比 Kafka 快 10 倍。然后写了篇博客说了一下自己 benchmark 的结果。\n做了两组实验：\n一组的是让 Kafka 每写一批数据就强行 Flush 到磁盘（不使用 PageCache），Redpanda 也 Flush 到磁盘（注：Redpanda Raft 协议的实现本身就要求每写一批数据就 Flush 到磁盘进行持久化），benchmark 结果如下\nFsync with every batch 可以看到 Redpanda 是比 Kafka 好不少。\n另外一组是让 Kafka 使用 PageCache，不显式 Flush 数据到磁盘，让操作系统自己控制 Flush 数据到磁盘。这个其实是用户部署 Kafka 的通常做法，我觉得这个才有参考意义。于是 benchmark 结果如下\nPage cache \u0026amp; no explicit flushes 对于workload 5，redpanda 说是因为他们实现的一个 bug，但是现在已经修复了。不过我总感觉它这个 benchmark 结果有点问题，因为我不理解 Kafka 在 从 200MB/s 到 0.5 GB/s，1GB/s，延迟升高了这么多，而到 1.25 GB/s，延迟又恢复正常了。\n根据 benchmark 结果（Fsync with every batch，red panda 认为这才是 safe workloads，即数据不会丢失；虽然其实 kafka 并不依赖 fsync），于是 red panda 画了个图：\n结论就是 RedPanda 的 P99 延迟比 Kafka 好非常多。\nRedPanda 的技术实现 实现 RedPanda 的初衷：\n需要一个可靠的复制协议，选择 Raft，因为 Raft 有一个严格的数学正确性的论证，并且简单容易理解 对于 Kafka 的ISR复制协议，个人感觉确实并没有一个严谨的关于正确性的论证，而且 Kafka 也不断地给这个复制协议打了不少补丁，去年也还是有一个补丁 KIP-966，只能说确实没那么可靠，问题还是不少。可以参考我写的 Kafka 复制协议不可不知的技术内幕 - 关于 Kafka 踩过的坑。\nPredictable 的延迟 Kafka 由于对 Page Cache 的使用，可以达到较低的延迟，但是如果 Page Cache 被污染了，比如在冷读的场景下 ，延迟就会变高，延迟很大程度取决于 Page Cache。Page Cache 这个确实也是个问题，所以很多厂商基于 Apache Kafka 提供云服务的话，都会魔改一下 Apache Kafka ，搞个冷热 Cache 隔离之类的。\nRedPanda 主要采用了如下技术：\nNo Page Cache 不使用 OS 的 general Page Cache，而是自己实现 Cache，这样 RedPanda 也可以根据访问 pattern，内存的使用量等来更好地 cache 数据；绕过了OS 的 page cache 也避免了 un-predictable 的延迟\n操作系统内核层面的调优\n禁用 Linux 的 Block-IO 自动合并 IO，减少昂贵的检查操作。给 Redpanda 提供确定性的内存占用，避免I/O过程中的内存波动。这个优化看起来有点反直觉，因为 IO 合并能 减少磁盘寻道时间，减少I/O操作次数，减少中断处理次数。但是 IO 合并也有一定的代价：内存连续性检查，检测请求的内存地址是否连续；重新分配和调整内存缓冲区，导致不确定的内存占用。而且RedPanda 是面向 SSD 设备的，在 SSD 设备上，IO 合并带来的好处则并没有那么明显，寻道时间基本为 0，并且 IO 不合并的话，可以带来更多的并行 IO，提高吞吐。\n合并中断（Coalescing interrupts）来平摊上下文切换的开销。将多个中断合并处理，减少CPU在用户态和内核态之间的切换次数。\n中断亲和（Interrupt affinity） ，强制 I/O 中断通知到最初发起请求的CPU核心，提高缓存局部性，减少跨核心通信开销\n这一堆优化看起来比较硬核，可以带来 10 ～30%的提升。\n减少文件的 metadata 更新的开销 正常的文件写入，每次写入数据都需要更新文件的 metadata，有一定的开销。为了减少这样的开销，RedPanda 的解决思路也很直接，就是预先分配一个比较大的 chunk，分配 chunk 的时间更新一次 metadata，往这个 chunk 里面写数据的时候就不需要再更新文件的 metadata 了。\n如下图所示，预先分配了一个 32 M 的chunk，每次写都写 4KB 的数据，这样写完一个 chunk 后，才会去分配下一个 chunk，更新一次文件的 metadata。\nDMA 写入优化 Redpanda 使用 Direct Memory Access（DMA） 来写入数据到磁盘中（注：DMA 写入需要和文件系统块做对齐，不然会对写入性能造成影响），DMA 写入操作是异步的，以最大化吞吐。\nRaft 指令重排序以减少 Flush 次数 受 CPU 处理指令的 pipeline 技术启发，decode 一系列 raft operation 的时候，调整指令的顺序，人为地减少 flush 操作，如下图所示：\n可以看到，之前需要 Flush 三次。优化后只需要 Flush 一次。\n每个线程绑定一个 CPU 核心 传统的多线程模型下：\n1 2 3 4 CPU核心0: 线程1, 线程2, 线程3, 线程4 (共享) CPU核心1: 线程5, 线程6, 线程7, 线程8 (共享) CPU核心2: 线程9, 线程10, 线程11, 线程12 (共享) CPU核心3: 线程13, 线程14, 线程15, 线程16 (共享) 多个线程竞争同一个核心的资源\nReadPanda 每个线程绑定一个 CPU 核心模型下：\n1 2 3 4 CPU核心0: 线程1 (独占) CPU核心1: 线程2 (独占) CPU核心2: 线程3 (独占) CPU核心3: 线程4 (独占) 优势：每个线程独占一个核心，无竞争，减少线程上下文切换的开销，用的是 Seastar 这个 C++ 写的框架。\n不过这篇博客还提了下其他优化，比如 预读，Write-behind buffering，Fragmented-buffer parsing，整数解码优化（Integer decoding），Streaming compressors，Cache-friendly lookup structures 等。\nKafka 的非官方回应 Confluent 的首席架构师 Jack Vanlightly 写了一篇博客回应了一下 RedPanda 的 Benchmark 结果，并不是以 Confluent 的立场，而是以个人的立场回击了一下。\n首先作者有几点疑问：\n对于 Kafka，我们通常根据网络和磁盘 IO 能力来调整云实例的大小，CPU 优化过的 Broker 真的会带来如此大的不同吗？Kafka 通常瓶颈都是在 IO 上，CPU 优化后真的会有很大效果吗？\n虽然 RedPanda 说他们做了很多优化，但是RedPanda 真的可以更快地写数据到磁盘吗？\n相比于 Kafka 目前的基于锁的并发模型，RedPanda 的 thread-per-core 真的可以很大地减少延迟吗？\nRedPanda 说对于 1G/s 的workload，Kafka 需要 9 个 i3en.6xlarge instance，但是 redpanda 只需要 3个，并且性能更高；不过作者并没有测试 9个，而是直接就用3个来测试；\n总结，RedPanda 宣称的比 Kafka 好被大幅夸大了，而在某些场景下，Kafak 比 RedPanda 更好；\n如果用 50 个而不是4个 producer，RedPanda 性能就会大幅下降\n运行超过 12 小时后，RedPanda 的性能也会大幅下降\n如果发生了 log 过期，RedPanda 延迟也会大幅上升\n如果 RedPanda 的record有key，RedPanda 性能也不行；因为需要根据key shuffle 不同的 partition，RedPanda 攒批效果会比较差\n设置 ack = 1，RedPanda 并不能达到 NVMe 的极限，但是 Kafka 可以\nKafka 也可以很好地处理消息堆积，但是 RedPanda 不可以\n首先，作者观察到，Redpanda 的 benchmark 代码有几个问题：\nlog.flush.interval.messages = 1，导致kafka每写一批数据都会 flush，Redpanda 说是为了数据不丢失，但是 Kafka并不依赖这个来保证数据不丢失。\n使用了 Java11，但 Kafka 在 Java 17 的表现更好，特别是对于开启了 TLS 的场景\nRedPanda 的 client 每 5 s 提交 offset，但是 kafka 默认是每次 poll 都提交\n于是作者修复后，得到了如下的 benchmark 结果\nFinding1\nReadPanda 在 50 个 producer 下表现很差；\nFinding2\nRed Panda 在跑了很久（12小时）之后延迟就会很大\n而kafka 影响很小\n作者认为是在 RedPanda 的随机 IO 的 access pattern下，SSDs 的性能下降，随机 IO 会给 SSD 带来比较大写放大（SSD driver 需要rewrite block 来进行 GC）。\nFinding3\n触发 segment 删除后，性能会陡增\n删除文件会对 ReadPanda 的性能造成影响\nFinding4\n设置了 record key 后，Red Panda 性能也比不上kafka了\nFinding5\nRedPanda 始终无法达到 IO 上限（2G/s），但是在 ack=1，no TLS 下，Kafka 可以达到\nFinding6\nRedPanda 无法很好地处理消息堆积；\n首先暂停 consumer，然后 produce 一批数据，来模拟消息堆积的情况；\n之后再恢复 consumer，对于RedPanda，总是会有消息堆积，即 consumer 总是无法即时地消费到最新的数据；但是 kafka 可以；\n作者也没解释原因，猜测可能是是 RedPanda 要从磁盘 load 数据，并且 cache 利用率不高，导致消费比较慢。\n总结\nRedPanda 的 benchmark 对应的 workload 不是足够通用的，并且在其他 workload 上，表现也是差于 Kafka 的。\n不过作者也承认，在某些 workload 上，RedPanda 的表现确实很惊艳。\n“Batch sizes need to be not too small, throughput shouldn’t be too high on high partition workloads and the drives need to be adequately provisioned with enough empty space to allow for the random IO nature of its storage layer. ”\n翻译一下就是：batch size 不能太小，吞吐量不应该太高，驱动器需要充分配置足够的空闲空间，以适应其存储层的随机I/O特性。\nRedPanda 宣称自己是下一代的 log 系统，thread-per-core架构，利用现代的 NVMe driver 等；但是作者不认为它的存储架构对 log 系统来说是最优的，甚至可能还是一个弊端；将一个partition map 成一个 segment file，然后在大量单独的 partition（segment file）上进行 flush 还是比较废。\n虽然 Kafka 也使用 one partition one file 的存储架构，但是 kafka 利用 OS 的 page cache 避免随机 IO。\n作者作为 Bookeeper 的 committer，也提到了 Bookeeper的思路，是将多个 partition 映射成一个文件，这样就完全是顺序 IO 了，不过也是一种 trade off，因为这样就意味着数据需要写两遍了，多一遍将 partition 拆分，来优化对单个 partition 的读；\n最后，作者总结 RedPanda 可以展示他们在某些 benchmark下比 kafka 好，kafka 也可以展示在其他 benchmark 下，Kafka 比 RedPanda 好；所以只有你用自己的场景去验证，才能知道哪个好；竞争是好的，但是虚假的宣传是没有帮助的。\n","permalink":"https://luoyuxia.github.io/posts/redpanda-%E5%92%8C-kafka-%E6%80%A7%E8%83%BD%E5%88%B0%E5%BA%95%E5%A6%82%E4%BD%95---%E6%9D%A5%E8%87%AA-redpanda-%E7%9A%84%E5%AE%98%E6%96%B9%E8%AF%84%E6%B5%8B%E5%92%8C-kafka-%E7%9A%84%E9%9D%9E%E5%AE%98%E6%96%B9%E5%9B%9E%E5%BA%94/","summary":"RedPanda宣称性能优于Kafka，但其基准测试存在争议，Kafka在多种场景下表现更优，实际性能需结合具体工作负载验证。","title":"RedPanda 和 Kafka 性能到底如何 - 来自 RedPanda 的官方评测和 Kafka 的非官方回应"},{"content":"前言：本来并不想聊 Hudi 的，因为我发现 Hudi 这玩意过于复杂和晦涩。但是有始有终吧，把数据湖系列都更新完吧。\n内部机制 读写流程 写流程：\nWriter 首先写入数据文件，然后写Timeline（后面的部分会介绍）文件来引用这些数据文件以提交这次写入。如下图所示：\n其中，数据文件会被组织成 File groups（后面的部分也会介绍），目前可以简单理解为分桶的概念，为了快速定位到某一条 key。\n读流程：\n读的时候会扫描 Timeline 来得到当前表的所有数据文件，然后读取这些数据文件即可。\nTimeLine Hudi 的所有提交操作都会写一个 instant 文件来记录这次提交。 instant 文件的格式为：\n\\[操作时间戳（以毫秒为单位）.\\[操作类型\\].\n\\[操作状态\\]。instant 文件按照递增的操作时间戳组织成 Timeline。\n操作状态分为如下三种：\nRequested\nInflight\nCompleted\nWriter 初始化的时候会先写入一个 .requested 文件，然后等到 Writer 开始写入数据前会写一个 .infight 文件，数据写完之后就会将写一个 .commit 文件，这个时候这次写入正式对外可见了。\n可以看到这个 instant 文件的操作时间戳需要是单调的， 如果两个 Writer 拿到了相同的时间戳，那就会存在相互覆盖的情况（如果底层 filesystem 不支持 put if absent 的话）。 然而 Hudi 让 Writer 端自己去保证操作时间戳的唯一性。Hudi V5 Spec 表示需要你自己去做这个保证，如果违背了这个约束，就会带来非预期的行为。\nPS：我觉得让 Writer 端自己去保证操作时间戳的单调对 Writer 端来说并不简单。\nFileGroup Hudi 表的数据被组织为分区，分区下面还有一层 FIleGroup，一个 FIleGroup 就是若干文件的集合，任何给定的主键都映射到一个 FileGroup。所以我感觉 FIleGroup 有点像是 Bucket 的概念。\n每一个 FileGroup 对应一个 file id，FileGroup 的所有文件都以这个 file id 为前缀，具体而言则是：\n\\[file\\_id\\]_\n\\[write\\_token\\]_\n\\[timestamp\\].\n\\[file\\_extension\\]。\nFileGroup 里面文件的具体组织取决于表是 Copy-On-Write 表还是 Merge-on-Read 表。\nCopy-On-Write 表 任何数据的修改都需要重写整个数据文件。下面是一个 Copy-On-Write 表的 FileGroup 里面文件的示例：\nMerge-On-Read 表 数据的修改都是在一个 base 文件上写一个 log 文件来记录数据的修改。读的时候就需要将这个 base 文件和 log 文件记录的修改进行 merge 以得到最终的数据。下面是一个 Merge-On-Read 表的 FileGroup 里面文件的示例：\nTimeLine \u0026amp; FileGroup 理解了 Timeline 和 FileGroup，我们可以看一下 Timeline 和 FileGroup 是如何组织在一起的，下图是一个具体的示例：\n一致性模型 写入流程详解 在理解 Hudi 的一致性模型之前，我们需要深入理解一下 Hudi 的写入流程，以 COW 为例写入流程如下图所示：\n写入端获取一个时间戳\n写入端写一个 .request 的 instant 文件\n查找 Key\n通过 index 查看键是否存在（用于将 upsert 标记为插入或更新）。\n如果这个 key 存在，则找到其对应的 FileGroup。如果不存在，则会为这个 key 分配一个 FileGroup。写入端会 在 FileGroup pool 中选择一个，选择哪个是不确定的，取决于具体的实现。\n读 File Slice\n加载 timeline，找到当前最大的一个 complete (.commit 文件) 的 instant 的时间戳，作为 targe timestamp\n找出所有 touch 到这个 file group 的 已 complete(.commit) 的 instant 文件，并且这些 instant 文件时间戳 \u0026lt;= targe timestamp\n读出这些 instant 文件对应的，在这个 file group 下的所有的file slice，如果发现有任何 file slice 的 timestamp 大于当前 writer 的时间戳，就直接 abort\n写 File Slice\nmerge 上一步读出来的 file slice，进行重写，在该 file group 写入新的 file slice。 获得表锁\n更新 index\n如果是插入操作，需要更新 index 来记录这个key 到 file group 的映射关系 乐观并发控制检查\n加载 timeline\nscan 出所有complete的 instant 文件，如果发现这些 instant 文件引用了任何一个大于 target timestamp 的 file slice，并且 file slice 属于当前 writer 要写入如的 file group，说明这期间有其他writer 进行了写入，并且写入出现了冲突，touch 到了相同的 file group，当前 writer 直接 abort\n写入完成\n写一个 complete 的 instant 文件\n释放表锁\nPS：感觉 Hudi 和其他湖格式还不太一样，Hudi 强依赖表锁，但是其实其他湖格式可以通过文件系统的 PutIfAbsent 能力来避免元数据的冲突。\n我们来详细解释一下乐观并发控制检查的机制，假设在某一时刻，两个 writer W1 和 W2 都准备进行提交，timeline 如下所示：\n然后：\nW2 获得了表锁，并成功提交了 file slice \u0026lt;file_id = 1, ts = 101\u0026gt;，释放表锁\nW1 获得表锁，但是发现存在一个已提交的 file slice， \u0026lt;file_id = 1, ts = 101\u0026gt;，并且其 ts 比 W1 读 File Slice 时对应的 ts = 50 还要大，于是就 abort 自己，提交失败。\n时间戳冲突的影响 写丢失： 如果两个 writer 用了相同的时间戳，且底层 filesystem 不支持 put if absent 的话，会存在互相覆盖的情况，如下图所示：\nOperation 2 会覆盖掉 Operation 1 的操作，导致 Operation 1 的操作丢失了。\nFIle slice 的覆盖 之前我们介绍过，Hudi File group 里面的 file slice 文件也是以 timestmap 为文件名的一部分的，\n\\[file\\_id\\]_\n\\[write\\_token\\]_\n\\[timestamp\\].\n\\[file\\_extension\\]。如果 timestmap 一样，也会存在冲突的情况，导致 Hudi 引用一个从未提交的事务写的文件，如下图所示：\nOperation1 已经写了一个 file_id = 1, ts = 100 的文件，假设文件名1_100.parquet\nOperation2 也有相同的 timestamp，然后也写一个 file_id = 1, ts = 100 的文件，文件名也为 1_100.parquet。但是这个时候失败了。这样 Operation1 写的文件 1_100.parquet 就会被 Operation2 写的文件覆盖了，虽然 Operation2 是一次失败的写入\n不过如果底层 filesystem 支持 put if absent 或者 file slice 的文件名能加个随机值就能解决这个问题。\n另外，博客的作者还做了一个实验，结论就是如果直接使用 current timestamp 的话，冲突的概率还是挺大，如下图所示：\nHudi 一致性模型 对 Writer 的要求 为了满足数据的一致性，Hudi 对 Wrier 有如下要求：\nTimestmap 必须是单调的\n开启并发控制检查，即检测这期间是否有其他 Writer 写入\n开启 key 冲突的检测\n底层文件存储支持 put if absent（如果 1 满足的话，4 也可以不满足）\n满足了上述条件后， Writer 写 Hudi 就不会破坏数据的一致性了。接下来我们看几种不满足上面条件的 case 来帮助理解。\nCase 1: 不开启并发控制检查 会出现写入丢失的问题，如下图所示：\nW1 写 k1，给 k1 分配一个 file group = 1，写入一个文件 f1，包含数据 k1=A\nW2 写 k2，给 k2 分配一个相同的 file group = 1，写入一个文件 f2，包含数据 k2 = B\nW1 提交成功，file group 只包含 f1\nW2 也提交成功，file group 只包含 W2 写的 文件 f2，只有数据 k2 = B，导致 W1 的写入丢失了\n核心原因是 W2 写入的时候，没有 merge W1 写入的内容。\n如果开启了并发控制检查的话，则会在第4步 W2 提交的时候，发现 W1 也写了相同的 file group，并且 W1 写的 ts 为1，比自己读数据用的 ts 0 要大，W2 读数据的时候没有 merge W1 的写入，于是检测到冲突，直接 abort。\nCase 2: 不开启 key 冲突的检测 会出现重复 key 的情况，如下图所示：\nW1 写 k1，发现 k1 不存在，给 k1 分配一个 file group = 1，写入 file group = 1\nW2 写 k1，也发现 k1 不存在，但是给 k1 分配一个不同的 file group = 2，写入 file group = 2\nW1 提交，更新 index，记录 k1 映射到 file group = 1，提交成功\nW2 提交，更新 index，记录 k1 映射到 file group = 2，提交成功。这个时候虽然 Hudi 的 index 认为 k1 对应的 file group = 2，但是其实有一条 key 为 k1 的数据映射到 file group = 1。并且这条数据依然存在于数据文件中\n如果开启 key 冲突的检测的话， 则会在第4步 W2 提交的时候，W2 发现 index 中记录的 k1 映射到 file group = 1 和自己的不一致，就会直接 abort 自己。\nCase 3: Timestmap 不单调，并且底层文件存储不支持 put if absent 同样会导致写入丢失：\nW1 获得 ts = 1，写 k1，对应 file group1 的数据文件 1_1.parquet\nW2 也获得 ts = 1，写 k1，同样也写了 file group1 的数据文件 1_1.parquet，覆盖了W1 写的 1_1.parquet\nW1 提交，提交成功\nW2 提交，进行冲突检测，检测到这期间 W1 写入了一个冲突的文件，于是 abort 自己。但是这个时候 W2 写的这个数据文件已经把 W1 写的数据文件覆盖了，造成不一致的情况。\n如果底层文件存储支持 put if absent 的话，则会在第2步 W1 写数据文件 1_1.parquet 的时候，发现这个文件已经存在，就直接 abort 。\n总结 Hudi 搞的这一套机制还是挺复杂的，比较容易出错，据我所知，不少公司在使用 Hudi 的时候出现数据正确性问题的时候，排查起来还是非常痛苦的。依然记得，前同事，某 Hudi PMC 玉兆老师排查一个 Hudi 的数据正确性问题排查了一周，那是我见过的，他每天早上来的最早的一周。\n","permalink":"https://luoyuxia.github.io/posts/%E6%B5%85%E6%B5%85%E8%81%8A%E4%B8%80%E8%81%8A%E5%9B%9B%E5%A4%A7%E6%B9%96%E6%A0%BC%E5%BC%8F%E7%9A%84%E5%86%85%E9%83%A8%E6%9C%BA%E5%88%B6%E5%92%8C%E4%B8%80%E8%87%B4%E6%80%A7%E6%A8%A1%E5%9E%8B---hudi-%E7%AF%87/","summary":"文章深入解析了Hudi的内部机制与一致性模型，重点阐述其基于Timeline和FileGroup的读写流程、乐观并发控制及对写入端的时间戳单调性等严格要求，揭示了其复杂性与潜在数据一致性风险。","title":"浅浅聊一聊四大湖格式的内部机制和一致性模型 - Hudi 篇"},{"content":"书接上文，接下来聊聊 Delta\n内部机制 Delta 的写入流程：\n写入对应的 Data File\n写一个 Delta Log 来记录这次写入，比如写了哪些新文件，逻辑删除了哪些老文件\n有如下几个点需要注意：\n这个 Delta Log 的文件名就代表了版本号，是一个严格递增的整数，假设一个 Delta 表提交了三次，则 Delta Log 如下所示：\n./_delta_log/00000000000000000000.json.\n./_delta_log/00000000000000000001.json.\n./_delta_log/00000000000000000002.json.\n这个 Delta Log 只引用这次写入涉及到的文件，不像 Iceberg \u0026amp; Paimon 一样会引用历史写入的文件，所以对于 Delta 表来说，如果需要读 Delta 表最新的数据，需要依次读取所有的 delta_log 文件，得到所有的数据文件。不过 Delta 表会定期将 deleta log 进行merge 一个 parquet 文件，这样其实只需要读取一个 parquet 文件和少部分 delta_log 文件即可。\nCopy-on-write \u0026amp; Merge-on-read 为了支持数据的删除和修改，即主键表模型，Delta 提供了两种模式：\nCopy-on-write 任何数据的修改都需要重写整个数据文件。写不友好，因为需要重写整个文件。读友好，不需要额外的 Merge 操作，直接读重写后的数据文件即可。如下所示：\nT1 时刻在数据文件 file1 写了三条数据：\u0026lt;red，1\u0026gt;，\u0026lt;green，1\u0026gt;，\u0026lt;blue，1\u0026gt;。\n如果要将 \u0026lt;blue，1\u0026gt; 删掉的话，T2 时候会写一个新的文件 file2，file2 包含两条数据 \u0026lt;red，1\u0026gt;，\u0026lt;green，1\u0026gt;，同时将 file1 标记为删除。\nMerge-on-read 数据的修改只需要写新的文件来记录数据的修改。写友好，只需要在写的文件中写被修改的数据。读不友好，读的时候需要进行额外的 merge，将新的文件和之前的数据文件 merge 起来，得到最终的数据。\nDelta 将数据的 Update 抽象成一次 delete，一次 insert，所以 Delta 需要标识一下哪条数据被删除了，也需要写新的数据。写新的数据比较简单，就是直接写一个 data file 就可以了。\n对于标识一下哪条数据被删除了，Delta 采用 Deltion Vector（DV）文件的方式，DV 文件会记录哪个数据文件的哪条数据被删除了，然后读数据文件的时候，将数据文件与 DV 进行 mask，忽略那些被 DV 标记为已删除的数据。如下图所示：\nT1 时刻在数据文件 file1 写了三条数据：\u0026lt;red，1\u0026gt;，\u0026lt;green，1\u0026gt;，\u0026lt;blue，1\u0026gt;。\n如果要将 \u0026lt;blue，1\u0026gt; 删掉的话，T2 时候会写一个新的文件 DV1 文件，DV1只记录要删除的数据所在的文件和其对应在文件的位置。\n一致性模型 Delta 如何解决元数据（Delta Log）冲突的问题 对于 Delta 来说，会存在两个不同的 writer 同时写相同文件名的 delta log 的问题，会存在一个 writer 会覆盖掉另一个 writer 的写入，导致数据的丢失，如下图所示：\nOpeation1 和 Opeation2 都基于表当前最新的版本 V1 开始写数据。\nOperation1 写了 File2，提交版本 V2，写了名字为 00002.json 的一个 delta log文件\nOperation2 写了 FIle3，但是它依然认为当前最新的版本是 V1，所以这次提交也还是写一个名字为 00002.json 的 delta log 文件，覆盖了 Operation1 写的 delta log文件，导致 Operation1 的写入丢失了\nDelta 可以通过两种方式来解决这个问题：\n文件系统的 PutIfAbsent 的能力 借助于文件系统的 PutIfAbsent 的能力，于 是Operation2 写 00002.json 的时候，发现这个文件已经存在了，就会 reload 当前最新的版本号，然后进行数据冲突的检测（这期间有别的 writer 进行了写入，需要进行数据冲突检测，数据冲突检测接下来的内容会介绍到），数据冲突检测通过后就写下一个版本的 delta log 文件，即 00003.json\nTable lock 但是对于某些不具备 PutIfAbsent 的能力的文件系统来说，比如 S3（注：其实应该S3 现在已经具备 PutIfAbsent 的能力了），就需要借助分布式锁（通常由外部的 catalog 提供，比如 hive catalog）来解决了。任何 Operation 在提交数据到 Delta 前都需要获得这把锁。\n于是Operation2 在准备提交名字为 00002.json 的 delta log前，需要获得这把锁，避免这期间有并发的提交。reload 当前最新的版本号，现在为 V2，然后进行数据冲突的检测，数据冲突检测通过后就写下一个版本的 delta log 文件，即 00003.json . Delta 如何解决数据冲突的问题 其实 Delta 解决数据冲突的方式也很简单，就是：\n如果从这个操作 开始时对应的版本 到 当前最新版本 之间的新增的数据文件（data file）和逻辑删除的（data file）对应的分区与这个操作对应的分区有 overlap，就认为冲突了，就直接 abort 这次写入。\n可以看到， Delta 解决数据冲突的方式也很简单粗暴，相比于 Iceberg 更为精细化（文件级别检测是否冲突）的冲突检测，Delta更加粗粒度，只是分区级别检测是否冲突。\n","permalink":"https://luoyuxia.github.io/posts/%E6%B5%85%E6%B5%85%E8%81%8A%E4%B8%80%E8%81%8A%E5%9B%9B%E5%A4%A7%E6%B9%96%E6%A0%BC%E5%BC%8F%E7%9A%84%E5%86%85%E9%83%A8%E6%9C%BA%E5%88%B6%E5%92%8C%E4%B8%80%E8%87%B4%E6%80%A7%E6%A8%A1%E5%9E%8B---delta-%E7%AF%87/","summary":"Delta通过递增版本的DeltaLog记录写入，采用Copy-on-write或Merge-on-read实现数据更新，并利用PutIfAbsent或表锁解决并发写入冲突，其一致性模型基于分区级冲突检测。","title":"浅浅聊一聊四大湖格式的内部机制和一致性模型 - Delta 篇"},{"content":"Paimon 和 Iceberg 在元数据层比较相似，所以接下来聊聊 Paimon，Paimon 支持非主键表和主键表，但是这篇文章我们只考主键表。\n内部机制 Paimon 分为元数据层和数据层，元数据层记录表的 schema，表有哪些文件等信息，而数据层则是具体的一堆数据文件。\n元数据层 每次写入的时候，Paimon 都会生成一组 metadata file，记录表当前的数据文件，如下图所示：\n最上层是 一个 Snapshot 文件，记录表的这个 snapshot 的信息，snapshot 文件会引用 3 个 manifest list 文件：\nBase Mainifest list：引用前一个 snapshot 包含的所有数据文件\nDelta Mainifest list：引用上个 snapshot 到这个snapshot 期间写入的所有数据文件\nIndex Mainifest list：引用这个snapshot 的索引文件，比如 deletion vector\n单独区分 Base Mainifest list 和 Delta Mainifest list 的好处是方便 list 出来每个 snapshot 增加了哪些数据文件，更好地进行流读。\n注：Paimon 的 snapshot 文件的格式为 snapshot-{snapshotid}，其中 snapshotid 是个递增的整数，提交快照2 其实就是写一个名字为 snapshot-2 的文件，Paimon 通过 catalog lock 来保证写一个名字为 snapshot-2 的文件是原子的，即原子的 PutIfAbsent 语义，如果不存在就写入，存在就 abort。避免多writer 场景下的互相覆盖。\n下面是一个经过多次写入后，Paimon 元数据的组织：\n数据层 Paimon 会将数据进行分区键（如果有的话）进行分区，然后再根据主键进行分桶。分桶的存在是为了给读取和写入提供更多的并行性。\n下面是一个 Paimon 数据组织的示意图：\n每个 bucket 的数据都组织为一颗独立的 LSM Tree，使用 LSM Tree 的原因是 LSM Tree 支持高效的 Upsert 和点查。LSM Tree 可以理解为 merge-on-read 模型，写入的时候直接写一个新的文件，在新的文件里面记录最新的数据。比如将要将数据 \u0026lt;jack, appple\u0026gt; 更新成数据 \u0026lt;jack, orange\u0026gt;，则会有两个数据文件：\ndata file1 包含 \u0026lt;seq=0, jack, appple\u0026gt;\ndata file2 包含 \u0026lt;seq=1, jack, orange\u0026gt;\n注意：数据文件额外记录了 seq 这个隐藏列，对用户是不可见的，这是用来标记哪一条数据是最新的。\n读取的时候，需要将 data file1 和 data file2 合并进行读取才能得到最终的\u0026lt;jack, orange\u0026gt;。这就导致只能用一个并发去读取这个 bucket的所有文件，限制比较大，读取效率较低。\n于是 Paimon 引入了 Deletion vector 文件\nDeletion vector Deletion vector 原理 Deletion vector 文件就是标记某个文件的某条数据被删掉了。如下图所示：\n就上面的例子而言，Deletion vector 文件会标记 data file1 的第一条数据被删了。于是就可以用两个并发单独去读 data file1 和 data file2。每个并发读取的时候都需要用数据文件和Deletion vector 文件做一次比对，看数据是不是被删了。读 data file1 的时候，发现 \u0026lt;seq=0, jack, appple\u0026gt;这条数据被删了，就不读出来。读 data file2 的时候，没有数据被删了，读出 \u0026lt;jack, orange\u0026gt;。\nDeletion vector 维护 可以看到 Deletion vector 需要对数据进行反查，得到这条数据所在的文件及其 pos，开销会比较大，所以并不会在写入的时候就生成 Deletion vector 。数据一开始会首先写入 L0层，Paimon 不会为 L0 层 的数据生成 deletion vector。Paimon 会在将 L0 层的数据 Compact 到更下层的时候才为其生成 deletion vector。\n如下所示：\nCompaction 之前，没有 DV，Compaction 之后才会 DV。这也就意味着，如果没有经过 Compaction，L0层还是有数据文件的话，Paimon 是没办法通过 DV 为读取进行多并发读取加速的，只能退化到单并发读取。\n下图是一个更复杂的例子：\n一致性模型 我们主要考虑如下两种写入 Topology 来看 Paimon 是否会存在一致性问题；\n多 writer 写不同的 bucket：多个 writer ，每个 writer 写不同的 bucket，writer 写的时候会进行 compaction，并且每个 writer 都会进行单独进行 commit\n多 writer 写相同的 bucket：多个 writer ，每个 writer 写相同的 bucket，writer 写的时候会进行 compaction，并且每个 writer 都会进行单独进行 commit\n多 writer 写不同的 bucket Topology 如下所示：\n假设 comact 操作和 write 操作同时在进行，则有如下的流程：\n写数据文件\nWriter\n写数据文件 Compactor\n读当前snapshot 的数据文件，compact 成新的数据文件 读最新的 snapshot，假设为 1，build 下一个 snapshot 2 文件\nWriter 和 Compactor 都基于最新的 snapshot 1 来 build 下一个 snapshot，写base minifest 文件， delta minifest 文件，一个临时的 snapshot 文件 原子性 rename snapshot 文件，以提交 snapshot\n重命名这个临时的 snapshot 文件为正式的 snapshot 2 文件\n如果 Writer 先 rename 成功了，则 Compactor rename 将会失败，跳转到第2步，重新进行提交，不会有一致性问题 ii. 如果 Compactor 先 rename 成功，Writer 的 rename 将会失败，跳转到第2步，重新进行提交，也不会有一致性问题\n由于总是原子性基于最新的 snapshot 来提交 snapshot，提交 snapshot 这个动作永远都是可序列化的，避免了元数据的冲突。并且不同 Writer 写入的都是不同 bucket 的数据，避免了数据冲突，所以不会存在一致性的问题。\n多 writer 写相同的 bucket Topology 如下所示：\n在这种多 writer 写相同 bucket 的 Topology 是会存在一致性的问题。\n一致性问题1：更新丢失 由于没有像 Iceberg 一样的数据冲突检测，Paimon 会存在数据丢失的问题。考虑如下的两种 case：\nCase1： 假设一开始有一条数据 {‘Jack’, ‘Yellow’, ‘Hiking’}\nWriter1 基于快照1读出数据 {‘Jack’, ‘Yellow’, ‘Hiking’}\nWriter2 基于快照1读出数据 {‘Jack’, ‘Yellow’, ‘Hiking’}\nWriter1 准备将 FavColor 字段修改为 ‘Blue’，写一条 +U 的数据，{‘Jack’, ‘Blue’, ‘Hiking’}\nWriter1 提交成功，表的最新快照变为快照2\nWriter2 准备将 FavHobby 字段修改为 ‘Cycling’，写一条 +U 的数据，{‘Jack’, ‘Yellow’, ‘Cycling’}，注意，它是基于 snapshot 1 的数据进行修改的，所以 FavColor 字段 还是 ‘Yellow’\nWriter2 准备提交，提交失败，因为当前快照已经变为 2\nWriter2 refresh 一下得到最新的 快照2，因为没有数据冲突检测，提交成功。\n最终这条数据变为 writer2 写的这条 {‘Jack’, ‘Yellow’, ‘Cycling’} 数据会把 writer1 写的这条 {‘Jack’, ‘Blue’, ‘Hiking’} 数据覆盖掉，于是只剩 {‘Jack’, ‘Yellow’, ‘Cycling’} 这条数据，导致 Writer1 的写入丢失了。\nCase2： 假设一开始有一条数据 {‘Jack’, 1}，Writer1 和 Writer2 都准备给 ‘Jack’ 的积分加1\n假设一开始有一条数据 {‘Jack’, 1}\nWriter1 基于快照1读出数据 {‘Jack’, 1}\nWriter2 基于快照1读出数据 {‘Jack’, 1}\nWriter1 准备将积分字段加1，修改为 {‘Jack’, 2}，写一条 +U 的数据， {‘Jack’, 2}\nWriter1 提交成功，表的最新快照变为 快照2\nWriter2 准备将 积分字段加1，修改为 {‘Jack’, 2}，写一条 +U 的数据， {‘Jack’, 2}，注意，它是基于 snapshot 1 的数据进行修改的\nWriter2 准备提交，提交失败，因为当前快照已经变为 2\nWriter2 refresh 一下得到最新的 快照2，因为没有数据冲突检测，提交成功。\n最终这条数据变为 {‘Jack’, 2}，破坏了可序列化隔离性\n一致性问题2：悬空的 deletion vector 如果两个 compaction 作业同时进行，可能会存在悬空 deletion vector ，即 deletion vector 文件指向一个已经被标记为删除的数据文件。\nCompactor1 将 L0 层的数据 compact 到 L1 层，并创建一个 deletion vector 指向 L2 层的这个有相同 PK 的老数据，假设为 data-file1\nCompactor2 compaction L2 层 的数据，重写 data-file1 到另外一个 data-file2，虽然这个时候 data-file1 其实已经被标记为删除了，但是还是有 deletion vector 指向这个 data-file1\n总结 Paimon 在每个 writer 写不同的 bucket 的 topology 下，不存在数据一致性问题，但是会在多 writer 写相同的 bucket 的 topology 下存在数据一致性问题，但是这样的 topology 并不是 Paimon 推荐的 topology， Paimon 社区也没怎么遇到过。不过如果要支持这样的 topology，做一下数据冲突检测也不会花费很大工作量。\n但是其实也有好处，就是如果你用了正确 topology 写 Paimon，就不会有数据冲突，数据冲突对使用体验，性能还是有很大的影响。\n","permalink":"https://luoyuxia.github.io/posts/%E6%B5%85%E6%B5%85%E8%81%8A%E4%B8%80%E8%81%8A%E5%9B%9B%E5%A4%A7%E6%B9%96%E6%A0%BC%E5%BC%8F%E7%9A%84%E5%86%85%E9%83%A8%E6%9C%BA%E5%88%B6%E5%92%8C%E4%B8%80%E8%87%B4%E6%80%A7%E6%A8%A1%E5%9E%8B---paimon-%E7%AF%87/","summary":"Paimon通过LSM树和Deletionvector优化主键表读写，多写者不同bucket无一致性问题，但同bucket写入可能导致更新丢失或悬空Deletionvector。","title":"浅浅聊一聊四大湖格式的内部机制和一致性模型 - Paimon 篇"},{"content":"前言 最近这段时间致力于 Fluss 与各大数据湖进行结合的工作，一直很想找个时间快速地，系统地学习下目前主流的数据湖格式。直到后来我看到了Jack 老哥的专题文章 The ultimate guide to table format internals - all my writing so far，浅浅阅读了一下，根据 Jack 老哥的一系列文章和自己的理解，梳理总结一下，帮助自己理解各大数据湖格式。\n对于每一种数据湖格式，都会覆盖数据湖格式的内部机制（如何将数据组成成文件，如何将文件组织成完整的表数据），以及一致性模型（处理多 writer 写的情况，如何保证数据的一致性）。\nPS：我看了 Jack 的很多文章，写的都非常好，建议大家可以去读一读。\n首先来聊一下 Iceberg：\n内部机制 写入流程 Iceberg 写入分为如下三步：\n将数据写入数据文件\n写一个 snpashot 文件来记录所有的数据文件，包括之前写入的数据文件和这次写入的数据文件\n将这个 snpashot 文件的 path 提交到 Catalog 中，Catalog 作为 source of truth，记录当前数据的版本。查询引擎从 Catalog 得到当前数据版本的 snpashot 文件的 path，从这个 snpashot 文件 list 出所有的数据文件，进行读取。\n对于第2步写 snapshpt 文件：\n其中 snpashot 文件记录数据文件的方式如下所示：\nSnapshot 会指向一个 manifest list 文件，这个manifest list 文件包含若干个 manifest，每个 manifest 会指向多个数据文件，一个 manifest 会包含多个 manifest entry，每个 manifest entry 执行一个数据文件\n下面是一个 Iceberg 表有多个 snapshot 的例子：\n对于第3步提交 snapshot 文件 path 到 catalog 这一步，要求必须是原子 CAS 操作，不然就会出现数据丢失的情况。\n比如当前 catalog 记录的 snapshot 是 snapshot1-，这个时候有两个 writer，W1，W2 同时进行写入，W1 基于 snapshot1 写了一个 snapshot-2- 文件，W2 也基于 snapshot1 写一个snapshot-2- 文件，如果提交不是原子 CAS 操作，那么 W1 可以提交成功，W2 也可以提交成功，这就会导致 W1 的写入被 W2 覆盖了，即 W1 这次写入的数据丢失了。\nIceberg 通过原子 CAS 的提交（通过外部的 catalog lock）避免这个问题，W2 提交的时候发现当前 catalog 记录的 snapshot 是 snapshot2了，就会abort 自己，不进行提交，如下所示：\n如何记录数据文件的添加和删除 所有的数据湖格式都需要记录每个 snapshot 添加了哪些文件，删除了哪些文件。Iceberg 通过 Manifest 文件来进行记录。对于每个 Manifest 文件：\n会有一个字段 added_in_snapshot 来记录这个 Manifest 文件是在哪个 snapshot 添加的。\nManifest 文件有多个 manifest entry，每个 manifest entry 指向具体的文件，会记录这个具体的文件的 status - ADDED（新增的文件），EXISTING（之前 snapshot 写入的文件），DELETED（删除的文件）\n下面是一个具体的示例：\nCopy-on-write \u0026amp; merge on read 为了支持数据的删除和修改，即主键表模型，Iceberg 提供了两种模式：\nCopy-on-write（COW） 任何数据的修改都需要重写整个数据文件。写不友好，因为需要重写整个文件。读友好，不需要额外的 Merge 操作，直接读重写后的数据文件即可。\n大致流程如下所示：\nIceberg metadata 如下所示：\n注意：Snapshot 2 的 metadata 需要把 data-1 文件标记为删除。\nMerge-on-Read（MOR） 数据的修改只需要写新的文件来记录数据的修改。写友好，只需要在写的文件中写被修改的数据。读不友好，读的时候需要进行额外的 merge，将新的文件和之前的数据文件 merge 起来，得到最终的数据。\nIceberg 将数据的 Update 抽象成一次 delete，一次 insert，所以 iceberg 需要标识一下哪条数据被删除了，也需要写新的数据。写新的数据比较简单，就是直接写一个 data file 就可以了。\n对于 “标识哪条数据被删除了”，iceberg 提供有两类 delete file 来进行标识：\nposition delete file，就是在 position delete file 中记录哪个数据文件的第几条记录被删除了。 如下所示：\n注意：这里会写两个文件，一个是 delete 文件，来标识老的数据被删除了。一个是新的 data（数据） 文件，记录新的数据。\nIceberg metadata 如下所示：\nEquality delete file：虽然 position delete file 很高效，但是 position delete file 的一个问题是计算引擎需要知道要删除的数据所在文件的 pos，写的时候需要反查，代价比较高。Equality delete file 则是用来解决这个问题的，Equality delete file 就是记录 “删除的数据的过滤条件”，任何满足这个过滤条件的数据都会被认为删掉了。 如下所示：\n注意，现在 delete 文件的内容变成 要删除的数据的过滤条件了，即 \u0026ldquo;name = jack\u0026rdquo;\nIceberg metadata 如下所示：\n我们注意到，manifest 额外多了一个 seq_no 的字段，这是用来避免 Equality delete file 把不应该被删除的数据标记为删除。比如在 snapshot 3，我新增了一个 name=jack 的数据，如果还对这条新的数据应用 Equality delete file 就会导致这条数据不会被读到，Equality delete file 只应该 apply seq_no \u0026lt; 2 的 data file 的数据。\nCompaction Iceberg 支持 compaction action 通过重写数据文件来减少数据文件数量，提高读取效率，如下图所示：\n数据最终被 compaction 成一个数据文件\n一致性模型 深入理解 iceberg writer 写入流程 Iceberg writer 的写入流程（只考虑 MERGE，DELETE，UPDATE 的 SQL 语句）如下图所示：\nScan 当前快照的 DataFile，记录下当前快照的 snapshot id，这个 snapshot id 是用于后面冲突检测的\n写数据文件\n刷新 metadata，得到此时最新的 metadata\n进行数据的冲突检测（后面会详细解释），看是否存在数据写入冲突，有的话，就直接 abort 这次写入\n写一个新的 snapshot 文件，来记录这次的写入的文件\n提交这个 snapshot 到 catalog，这个一个原子的 CAS 的操作，看 catalog 当前记录的 snapshot 是不是这个 writer 认为最新的 snapshot，如果是的话，提交成功。如果不是的话，返回第 3 步，进行重试。\n数据冲突检测 Iceberg 通过数据冲突的检测来保证多 writer 同时进行写入的情况下，将有数据冲突的写入 abort，从而提供数据的一致性。\n接下来我们来看下 Iceberg 支持的数据操作，以及其潜在的数据冲突；\nAppendFiles 操作 这个操作比较简单，是针对非主键表而言的，就是向 Iceberg 表中追加数据文件，这个是不会有冲突的，所以 Iceberg 不会对 AppednFiles 操作进行冲突检测。\nOverwrite（Copy-on-write） 操作 Overwrite 操作会 添加新的数据文件 \u0026amp;\u0026amp; 将已有的数据文件标记为已删除。该操作可能存在以下的冲突：\n两个 Overwrite 操作都将相同的数据文件标记为删除，然后都写了不同的新数据文件，如下图所示： 这样，data-2 和 data-3 都会被认为是要读的数据文件，造成数据的重复和不一致。\nOverwrite 操作和 RowDelta（Merge-on-read） 操作的冲突。RowDelta 首先通过 delete file 将一个数据文件 data1-file 的某条数据 标记为已删除。然后这个时候 Overwrite 操作重写了数据文件 data1-file 成 data2-file，将 data1-file 的这个数据文件标记为已删除。于是在 Iceberg 中，虽然 数据文件 data1-file 被标记为已删除，但是 delete file 中的 delete entry 还依然引用着数据文件 data1-file，出现不一致的情况。 Iceberg 通过如下三种校验来避免 Overwrite 的冲突：\nFail missing delete paths validation\n思路很简单，就是看一下这个操作要删除的文件是不是已经被标记为删除了。以上面的第一种冲突为例，Operation B 提交的时候，会发现它要删除的文件 data1-file 已经被标记为删除了，于是就检测到冲突了。\nNo new deletes for data files validation\n检测从这个 Overwrite 操作开始时对应的 snapshot id 开始，到当前最新的 snapshot id 是否有同时满足如下条件的 delete 文件，如果有，则认为数据冲突了：\ndelete 文件的 sequence number \u0026gt; Overwrite 操作开始时对应的 sequence number（表示是在Overwrite 操作开始后添加的） delete 文件是由 Delete 或者 OverWrite 操作添加的 delete 文件 匹配 Overwrite 操作用到的过滤条件（可下推的），比如 update xxx where = jack，其中 where = jack 就是这个过滤条件。Iceberg 会通过文件的统计信息看是否匹配。注意会存在 统计信息认为匹配，但是其实并不匹配的情况，这个没办法避免。如果过滤条件不设置的话，就会认为所有的 delete 文件都匹配。 如果 delete file 没有统计信息，比如 pos delete file，也会认为匹配。 delete 文件引用了一个被这个 Overwrite 操作标记为删除的数据文件 这个可以解决上面提到的第二种冲突。\nAdded data file validation\n检测从这个 Overwrite 操作开始时对应的 snapshot id 开始，到当前最新的 snapshot id 是否有同时满足如下条件的数据文件，如果有，则认为数据冲突了。\n被 APPEND 或者 OverWrite 操作添加 数据文件匹配 Overwrite 操作用到的过滤条件（可下推的），比如 update xxx where = jack，其中 where = jack 就是这个过滤条件。Iceberg 会通过数据文件的统计信息看是否匹配。注意会存在 统计信息认为匹配，但是其实并不匹配的情况，这个没办法避免。如果过滤条件不设置的话，就会认为所有的数据文件都匹配。 Iceberg 的这三种冲突检测实际是需要上层引擎手动调用进行检测，Spark 基于上述的冲突检测，实现了快照隔离和可序列化隔离。\nSpark 实现快照隔离：调用 validateNoConflictingDeletes 开启 Fail missing delete paths validation 和 No new deletes for data files validation 检测。只检查是否有新的删除文件影响了它要覆盖的数据，不检查是否有新的数据文件出现在它要覆盖的范围内。但是会存在如下的一个问题，其实也是快照隔离级别会出现的写倾斜的问题：\n考虑一个经典的医生值班的例子，假设医院规定，任何时候至少必须有一位医生在值班，表的数据如下所示：\n表：doctors_on_call\ndoctor_id name is_on_call 1 Alice true 2 Bob true Alice 和 Bob 通过 MERGE INTO 语句执行操作，如果还有其他人在值班，我就不值班。\nMERGE INTO 语句\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 -- Alice 执行的语句 MERGE INTO doctors_on_call target USING (SELECT 1 as doctor_id) source ON (target.doctor_id = source.doctor_id) WHEN MATCHED AND EXISTS ( SELECT 1 FROM doctors_on_call WHERE is_on_call = true AND doctor_id != 1 -- 确保还有别人在值班 ) THEN UPDATE SET target.is_on_call = false; -- Bob 执行的语句 MERGE INTO doctors_on_call target USING (SELECT 2 as doctor_id) source ON (target.doctor_id = source.doctor_id) WHEN MATCHED AND EXISTS ( SELECT 1 FROM doctors_on_call WHERE is_on_call = true AND doctor_id != 2 -- 确保还有别人在值班 ) THEN UPDATE SET target.is_on_call = false; 于是：\nAlice 基于当前快照1 执行语句，Bob 还在值班，将自己的 is_on_call 设置为 false。即 Iceberg 将 Alice 所在数据文件 data-file1 标记为删除，写一个新的数据文件 data-file3 ，记录 \u0026lt;Alice, false\u0026gt; ，准备提交\nBob 也基于当前快照1 执行语句，Alice 还在值班，将自己的 is_on_call 设置为 false。即将Iceberg 将 Bob 所在数据文件 data-file2 标记为删除，写一个新的数据文件 data-file4，记录 \u0026lt;Bob, false\u0026gt; ，准备提交\nBob 提交成功，没有任何冲突\nAlice 也提交成功，因为它也没检测到任何冲突\nFail missing delete paths validation，检测通过，因为 Alice 要标记删除的 data-file1 并没有被 Bob 标记为删除\nNo new deletes for data files validation，检测通过，因为没有任何 delete 文件\nSpark 实现可序列化隔离：调用 validateNoConflictingDeletes 和 validateNoConflictingData 开启 Fail missing delete paths validation ， No new deletes for data files validation ，Added data file validation 检测。既检查是否有新的删除文件，也检查是否有新的数据文件出现在它要操作的范围内。对于上面提到的医生值班的例子，Alice 提交的时候，进行 Added data file validation 检测，发现 Bob 添加了新的数据文件，于是直接 fail。注意这里没办法用到过滤条件，因为过滤条件是 exists，不能下推。\nRowDelta（Merge-On-Read） 操作 RowDelta 操作会 添加新的数据文件 + 新的 delete 文件来标记数据被删除了。该操作可能存在以下的冲突：\nRowDelta 操作创建了一个新的 delete 文件来标记某个数据文件 data1-file 的某条数据被删除了，但是同时另外一个 Overwrite 操作已经把 data1-file 标记为已删除。如下图所示： 如果 Opeation B 不做任何冲突检测的话，最后会变成：\n于是会有两条 name = sarah 的数据， \u0026lt;sarah, orange\u0026gt;，\u0026lt;sarah, cherry\u0026gt;\n两个 RowDelta 修改相同的一条数据，一个 RowDelta 删除这条数据，另外一个 RowDelta 更新这条数据。于是就会导致两个 delete 文件都指向了相同的一条数据，一个数据文件包含更新后的这条数据。出现冲突的 case 如下所示：\nWriter 1 开始 update 操作，UPDATE Fruits SET FavFruit = \u0026lsquo;banana\u0026rsquo; WHERE Name = \u0026lsquo;jack\u0026rsquo; Writer 2 开始 delete 操作，DELETE FROM Fruits WHERE Name = \u0026lsquo;jack\u0026rsquo; Writer 1 添加一个 delete 文件，标记现有的 data-1 文件中的 \u0026lsquo;jack\u0026rsquo; 这一条数据无效，然后添加一条数据 \u0026lt;\u0026ldquo;jack\u0026rdquo;, \u0026ldquo;banana\u0026rdquo;\u0026gt; 到 data-2 文件 Writer 2 也添加一个 delete 文件，标记现有的 data-1 文件中的 \u0026lsquo;jack\u0026rsquo; 这一条数据无效 Writer 1 成功提交 Writer 2 也成功提交 于是 Reader 仍然能读到 \u0026lt;“jack”, “banana”\u0026gt; ，但是不论是 Writer 1 先执行，然后 Writer 2 后执行；还是 Writer 2 先执行，Writer 1 后执行。都不应该读到 \u0026lt;“jack”, “banana”\u0026gt; 这条数据。\nIceberg 通过如下三种校验来避免 RowDelta 的冲突：\nData files exist validation\n检测从这个 RowDelta 操作开始时对应的 snapshot id 开始，到当前最新的 snapshot id 是否有同时满足如下条件的数据文件，如果有，则认为数据冲突了：\n已经被标记为删除了 是由 Overwrite 操作将其标记为删除的 RowDelta 操作的 delete file 会引用这个数据文件 在上面的 case1 中，Opeation B 的 delete file 引用了 data-1 file，但是 data-1 file 已经在 snapshot-2 中被 Overwrite 操作标记为删除了，于是检测到冲突了，Opeation B 会 abort。\nNo new delete files validation\n检测从这个 RowDelta 操作开始时对应的 snapshot id 开始，到当前最新的 snapshot id 是否有同时满足如下条件的 delete 文件，如果有，则认为数据冲突了：\ndelete 文件的 status 为 ADDED，表示是这期间新增的delete 文件 delete 文件的 sequence number 大于 RowDelta 操作开始时对应的 sequence number（表示是在RowDelta 操作开始后添加的） delete 文件是由 Delete 或者 OverWrite 操作添加的 delete 文件 匹配 RowDelta 操作用到的过滤条件 对于上面提到的 case 2的情况，Writer 2 的时候发现 writer1 新增加了一个 delete 文件，就会直接 abort 掉自己的提交。\nAdded data file validation\n和 Overwrite 的 Added data file validation 完全一样，这里重复一下检测流程：\n检测从这个 RowDelta 操作开始时对应的 snapshot id 开始，到当前最新的 snapshot id 是否有同时满足如下条件的数据文件，如果有，则认为数据冲突了。\n被 APPEND 或者 OverWrite 操作添加 数据文件匹配 RowDelta 操作用到的过滤条件，比如 update xxx where = jack，其中 where = jack 就是这个过滤条件。Iceberg 会通过数据文件的统计信息看是否匹配。注意会存在 统计信息认为匹配，但是其实并不匹配的情况，这个没办法避免。如果过滤条件不设置的话，就会认为所有的数据文件都匹配。 RewriteFiles 操作（即 compaction） 执行如下两种冲突检测来避免 RewriteFiles 操作和 OverwriteFiles，RowDelta，RewriteFiles 操作冲突\nFail missing delete paths validation\nNo new deletes for data files validation\n","permalink":"https://luoyuxia.github.io/posts/%E6%B5%85%E6%B5%85%E8%81%8A%E4%B8%80%E8%81%8A%E5%9B%9B%E5%A4%A7%E6%B9%96%E6%A0%BC%E5%BC%8F%E7%9A%84%E5%86%85%E9%83%A8%E6%9C%BA%E5%88%B6%E5%92%8C%E4%B8%80%E8%87%B4%E6%80%A7%E6%A8%A1%E5%9E%8B---iceberg-%E7%AF%87/","summary":"本文深入解析Iceberg数据湖格式的内部机制与一致性模型，涵盖写入流程、快照管理、并发控制及冲突检测机制，确保多写者场景下的数据一致性。","title":"浅浅聊一聊四大湖格式的内部机制和一致性模型 - Iceberg 篇"},{"content":"本来并不打算专门写篇文章介绍 Ursa，因为Ursa 的架构和之前我写的文章KIP-1150 浅浅解读：聊一聊 Kafka社区的存算分离提案的架构非常像，但是考虑到 Ursa 居然拿了 VLDB-2025 的 best industry paper，水一篇文章简单介绍一下。\n其实在最早去年FFB上云邪老师介绍了 Fluss 后，StreamNative 搞 Ursa 的那帮人很感兴趣，就找我们交流了一下 Fluss 。然后我们让他们分享了一下他们内部搞的 Ursa，于是我在那个时候就了解到了 Ursa。\n背景 Kafka 虽然已经是流存储事实的标准了，但是 Kafka 也有很多问题：\n成本高：存算一体架构导致依赖昂贵的本地磁盘，扩缩容的运维成本，ISR 复制协议带来的跨 AZ 复制数据的成本，\n不能直接用来高效分析：Kafka 从来不是用来进行高效分析的，要分析 Kafka 里面的数据，都需要运维一个新的数据 pipeline，把 Kafka 的数据灌到湖仓中\nUrsa（兼容 Kafka 协议） 就是专门用来解决这两个问题的：\nUrsa 是存算分离的架构，直接写对象存储\nUrsa 内置 Compaction service，自动将 Ursa 的数据 compact 成湖格式\n架构介绍 Ursa 的架构图如下所示：\nUrsa 主要分为三大部分：\nBroker：无状态的 broker，负责接收 client 的读/写请求\nStream Storage：数据的存储层，将存储与计算节点（Broker）解耦合，分为 WAL 和 LakeStorage。Broker 会直接将数据写到 WAL 中，WAL 是可插拔的，可以是低延迟的 BookKeeper，也可以是延迟稍微高一点，但更廉价的对象存储。此外，Ursa 还会有一个 Compact service 将 WAL 里面的数据 compact 成湖格式\nMetadata service：负责元数据的管理，以及一些协调工作。因为 Ursa 是 leader-less 的，所以可能会存在两个 leader broker 同时写入数据，所以需要一个中心化的协调者来协调 log offset 的分配工作，不然两个不同的 leader broker 写入的数据可能会有相同的 log offset。\n读/写流程 写流程 写流程如下所示：\nWriters 首先将数据写入到 Broker 的 WAL Buffer\nWAL Buffer 攒够了足够多的数据，或者等待了足够长的时间后，将数据持久化到 WAL（对象存储 WAL） 中\n提交到 metadata service，让 metadata service 为这批数据分配 log offset，记录对应 offset 到 WAL 文件所在的 pos。对于 Kafka 来说，写入一批数据后，需要为这一批数据分配 log offset， consumer 也需要可以根据这个 log offset 快速定位到数据并进行消费。这就是 Ursa 的 Metadata service 要干的事情， Ursa 为 log offset 构建了一个 kv-store 来 索引 log offset ，用来快速根据 log offset 来找到该数据在哪个文件的哪个 pos。其中 index-entry 的 Key 和 Value 的格式如下所示：\nKey：(StreamID, OffsetEnd, CumulativeSize) 其中：StreamID 可以理解为一个 Partition，Usra 为了唯一标识一个 Partition，引入一个 StreamID 来标识它\nOffsetEnd 是这一批数据最大的 log end offset，CumulativeSize 记录了这个 Partition 从开始直到这个 index-entry 总共有多少bytes 的数据。论文并没有说 CumulativeSize 是用来干嘛的，我怀疑可能是为了快速 fetch 到指定 bytes 数据的 index-entry。\nValue: （Location, FileType, EntryCount, MessageCount, OffsetInObject, EntryOffsets） 其中：Location 为文件名，FileType 为文件类型（WAL 或者 lake File），MessageCount 为这一批数据的消息数量，OffsetInObject 为这批数据在文件上的具体 pos，EntryOffsets 我没太看懂是干啥的，看起来像是为这一批数据再次构建了一个小的 index，用来快速seek 这一批数据的某行数据。基于此，这个 EntryCount 其实表示这个小的 index 包含的 index-entry。\n于是，提交到 metadata service 的具体流程如下：\n通知 Writers 写入成功了 读流程 假设从 offset x 开始读取数据，则流程如下所示：\nBroker 去 Metadata Service 查询第一个 OffsetEnd \u0026gt; x 的 index entry\nBroker 通过这个 index entry 定位到对应的文件和pos，读取数据\n如果数据在 WAL中，直接读取即可。但是如果数据在 Lake 上，需要将列式的数据转成行式的方式（因为 Ursa 需要兼容 Kafka 协议，未来会考虑扩展 Kafka 协议，支持 Arrow 格式）。会带来一定的 CPU 开销，但是 Parquet 高压缩率会带来更少的 IO\n另外 Ursa还搞了个基于一致性 Hash 的 cache 来 cache 数据，我的上一篇文章有提到过，就不再赘述了。\n性能评估 其实我比较好奇的是 Usra 和 kafka 比的性能测试，但是并没有，只是简单 benchmark 了一下 Usra 在不同 workload 下的表现。结论如下：\nTopic \u0026amp; Partition 数量和消息的大小均不影响吞吐，维持在 5G/s\n1G/s produce 的 workload 下，p99 延迟 1s 内，p99999延迟2，3秒\nconsumer 大规模数据回拉下，produce 延迟表现依然很好，除了produce达到 2G/s 的时候会有个延迟陡增。很奇怪，论文也并没有解释为什么。\n总结 我喜欢 Ursa 的 diskless + lake native 的架构，简单，优雅。\nUrsa 提到了对于数据的更新，未来会支持生成 Changelog。（：这不就是 Fluss 的主键表\nUrsa 提到未来会支持上层计算引擎将 WAL 的数据和 Lake 的数据合并起来，实现 real-time 的 OLAP。目前上层计算引擎只能查询 compact 到 Lake 的数据，数据有一定的 delay，需要再等一次 compact后， 数据才对上层计算引擎可见。（：这不就是 Fluss 的Union Read\n","permalink":"https://luoyuxia.github.io/posts/vldb-2025-best-industry-paper---ursa--a-lakehouse-native-data-streaming-engine-for-kafka/","summary":"Ursa是兼容Kafka协议的湖仓原生存算分离流引擎，通过将数据直接写入对象存储并内置Compaction服务，降低存储成本并支持高效分析。","title":"VLDB-2025 Best Industry Paper - Ursa: A Lakehouse-Native Data Streaming Engine for Kafka"},{"content":"有段时间没更新了，前段时间 Aiven 公司在 Kafka 社区提了一个 Kafka 存算分离的 KIP-1150，最近基于这个 KIP，用 Rust 把 Fluss 的 Tablet Server 简单 POC 了一下（PS：等完善了写篇文章和大家分享一下），代码写累了，沉淀一下，写篇文章聊下 KIP-1150。\n注： KIP-1150 也还在讨论中，不一定会被 Kafka 社区接受，但我觉得整体的思路是 OK 的，值得参考学习。\nKIP-1150 要解决的问题 简单来说，KIP-1150 就是让 Kafka 不要在本地存数据，直接用远程的对象存储如 S3 来存数据，即存算分离架构。关于存算分离的好处，之前写过一篇文章介绍过，建议先阅读一下。但是我发现我一直忽略了一个很重要的好处，这也是 KIP-1150 反复强调的一个好处，那就是可以节省 跨可用区数据复制 的网络成本。\n什么是跨可用区数据复制的成本呢？如果我们需要部署高可用 Kafka 集群的话，通常需要把其中一个从副本放在另外一个可用区上，实现跨可用区的容灾，但是从副本需要跨可用区从主副本复制数据，而这跨可用区的网络流量是需要付费的（Aws，Google Cloud收费，Azure 免费），这就是 跨可用区数据复制的成本。这个成本是非常高的，根据 Aiven 公司写的 Diskless Kafka博客，整个成本占总成本的 90%。\n而对象存储本身就是跨可用区高可用的，直接写到对象存储就自动实现了跨可用区容灾，也就没有了跨可用区数据复制的费用。\nKIP-1150 解决思路 KIP-1150 整体架构 Aiven 公司提出的这个架构和 WarpStream（现在已经被 Confluent 收购），StreamNative 公司搞的 Ursa 非常像， 至少我现在还并不能搞清楚它们这三个在架构上的区别。我合理怀疑是 WarpStream 提了这套架构，延迟也能干到1秒内，证明了这套架构的可行性，然后大家借鉴参考\u0026hellip;\u0026hellip;\n不同与传统 Kafka，KIP-1150 提出的这个架构是 leader-less 的，即没有明确的一个主节点，主节点在 Kafka 里意味着 producer 只能向这个主节点 produce 数据，而在 leader-less 下，则意味着 producer 可以向任何节点produce 数据。这也是为了减少跨可用区的网络开销，试想一下，如果主节点在可用区 AZ1，但是用户的 producer 却在可用区 AZ2，那么就需要承担 AZ2 到 AZ1 的网络开销了。\n整体架构如下所示：\nProducer 将数据发送到 Broker，Broker 将数据上传到 Object Storage，然后再通过一个 Batch Coordinator 分配数据对应的 log offset，将数据对应的 metadata 信息，比如在 Object Storage 的哪个文件上，文件 offset 等信息也持久化到 Batch Coordinator中。metadata 信息持久化到了 Batch Coordinator 则认为数据写成功了。\nConsumer 消费数据的时候，根据要消费数据的位点从 Batch Coordinator 拿到数据在哪个文件上，文件 offset 等信息，然后根据这些信息去文件上读数据。\n注：这个 Batch Coordinator 我们可以先将其看作是一个持久化的 Broker 共享的 KV，后面会详细介绍这个 Batch Coordinator。\nWrite Path 写数据的整体流程如下所示：\nProducers 将数据发送到任意的 Broker\nBroker 将发过来的数据在内存中进行 buffer\nBroker 等攒够了足够的数据（8M），或者超过250 ms 了，就上传到对象存储上；\nBroker 与 ControlPlan（其实就是 BatchCoordinator）交互，让它为这一批数据分配 log offset，并将数据的 metadata 信息，如文件名，log offset 持久化\nBroker 返回给 Producer 写入成功的 ACK\n这个 KIP 做了一个 produce 数据的延迟分析：\nBuffer 数据: 最多 250ms\n上传到对象存储: P99 ~200-400ms, P50 ~100ms\n与 Batch Coordinates 交互（提交到 Batch Coordinates）: P99 ~20-50ms, P50 ~10ms\n目标 Produce request 延迟： P50 ~500ms， P99 ~1-2 sec\nRead Path Consumer 发送 fetch 请求到任意 Broker\nBroker 请求 ControlPlan 得到要 fetch 的数据的 metadata 信息，比如文件名，所在文件的位点，其对应的 log offset 等信息\nBroker 根据 metadata 信息去对象存储上读数据\nBroker 将 log offset 信息填充到 Log Batch 中，返回给 Consumer\n注意：传统 Kafka 中，log offset 信息是记录在数据块（record batch ）中的，作为数据块的 header 一部分存储在文件中。但是这个架构下。log offset 信息的 source of truth 是记录在 BatchCoordinator 中的，所以需要消费的时候，需要请求一下 BatchCoordinator 得到 source of truth 的 log offset。\nKIP-1150 核心组件 - BatchCoordinator Batch Coordinator 其实是个协调者的角色，协调在 leader-less 下，不同 broker 写相同的 partition，log offset 到底应该怎么分配，做一个全局的 log offset 分配，这些不同的 broker 最终都会请求到 Batch Coordinator，Batch Coordinator 为这些请求分配 log offset。当然 Batch Coordinator 还有一个主要的功能就是持久化元信息。\n这个 KIP 提出用 Kafka 的 inner topic 来作为 Batch Coordinator，当然 Batch Coordinator 的实现是可插拔的，Aiven 开放出来的代码的 Batch Coordinator 底层实际上用的是 Postgres。\n用 Kafka 的 inner topic 来作为 Batch Coordinator 大概如下所示：\nCommitBatch 这一类写请求发送到 Leader Broker，对于 FindBatch 这一类读请求，可以直接发送到 Follower Broker。注意 Kafka 的 Topic 是不能直接查询的，所以这个KIP 说同时需要在内存中保存这些信息，然后本地搞个 sqllite，周期性地持久化到 sqllite。个人感觉有点复杂，不如直接用 Postgress 好了。\nKIP-1150 核心组件 - Object Cache 其实这个 KIP 并没有介绍 Object Cache 的实现，但是显然 Object Cache 的实现是非常重要的，这个 Object Cache 是把在对象存储上的数据 cache 起来，避免频繁地直接从对象存储上读数据，提升性能，并减少对象存储 API 调用的成本。\n虽然这个 KIP目前还没有介绍 Object Cache 的实现，不过 WrapStream 的博客 Minimizing S3 API Costs with Distributed mmap介绍了它们搞的 cache。\n简单来说，他们是基于一致性哈希搞了个分布式的 Cache，对象存储的 object key 就是 hash key，每个 Broker cache 一部分数据。\n如下图所示（Agent 其实就是 Broker）：\n比如 Agent1 要读 file3 的数据，基于一致性hash 计算发现应该去 Agent 2 拉数据，然后就向 Agent2 请求数据，Agent2 发现本地没有这个数据，就去对象存储上 fetch 数据，然后返回给 Agent1。\nAgent3 也要读 file3 的数据，也向 Agent2 请求数据，Agent2 发现数据在本地，就直接返回数据给 Agent3\nKIP-1150 核心组件 - Object Compact 上传到对象存储的文件会包含多个 Kafka Topic \u0026amp; Partition 的数据，因为 Broker 会将这一段时间 produce 到所有 Topic \u0026amp; Partition 的数据都组织成一个文件，上传到对象存储中，这个也是为了减少小文件的数量。但是一个文件包含多个 topic 的数据对消费显然是不友好的，因为 Consumer 消费通常都是只消费一个 topic 的数据，文件包含多个topic 的数据会不利于顺序读。\n所以 Object Compact 是必须的，Object Compact 就是将文件重新组织，尽量让相同的 topic \u0026amp; partition 的数据都组织到一个文件中，提高顺序读的效率。\n然而这个 KIP 还没有写完 Object compact，等写完了再来更新下吧。\n其他 其实 Kafka 社区还提了个其他简单的，也能节省跨可用区数据复制的方案，Kafka 社区还在讨论最终采用哪个方案，感兴趣的可以去讨论邮件里面追踪下。等这个讨论结束我也写篇文章和大家聊一下最终方案后面的思考和 trade off。\n","permalink":"https://luoyuxia.github.io/posts/kip-1150-%E6%B5%85%E6%B5%85%E8%A7%A3%E8%AF%BB%E8%81%8A%E4%B8%80%E8%81%8A-kafka%E7%A4%BE%E5%8C%BA%E7%9A%84%E5%AD%98%E7%AE%97%E5%88%86%E7%A6%BB%E6%8F%90%E6%A1%88/","summary":"KIP-1150提出Kafka存算分离架构，通过将数据存储至远程对象存储（如S3）并采用无Leader设计，降低跨可用区复制成本，提升可扩展性与成本效率。","title":"KIP-1150 浅浅解读：聊一聊 Kafka社区的存算分离提案"},{"content":"前段时间学习了一下 Lance，最近随着 Lance 被提及的越来越频繁，写篇文章聊一下自己对 Lance 的理解。\n介绍 Lance 宣称自己是为机器学习和大语言模型（LLM）而生的列式数据格式。Lance 包含两部分定义，一部分是文件格式，另一部分是表格式。\n其中 Lance 的文件格式类比 Parquet，定义的是怎么将数据组织成文件，以适应上层引擎访问的需求。而表格式类比于 Iceberg，定义的是怎么将这些文件组织起来，提供 ACID 语义，二级索引等。\n表格式的这部分与 Iceberg 差不多，更重要的是 Lacne 的定义的文件格式，所以这篇文章主要聚集于 Lance 的文件格式的定义。\nWhy lance 为什么需要一个新的文件格式，已有的 Parquet 格式不好吗？Parquet 格式虽然好，但是它是为大数据分析而生的，数据的组织也是为了适配上层引擎分析的需求，并不能很好地适应机器学习/AI 的 workload，主要分为以下几个方面。\nParquet 不能很好支持随机访问 为什么需要随机访问 随机访问指的是随机访问整个数据集的某几条数据。随机访问在 AI workload 是比较重要的，因为我们经常需要将整个训练集进行 shuffle，划分为训练集和验证集，而 shuffle 就依赖根据数据 id 快速访问数据的能力。并且在训练过程中，我们经常需要看一下第多少多少条数据是什么样子的，这也依赖随机访问的能力。\n为什么 Parquet 不能很好支持随机访问呢？ 在 Parquet 中，如果要访问一条数据，假设通过某一列 id 来访问这条数据。我们需要读取 Parquet 文件的 footer 的统计信息，找到这条数据在哪个 Parquet 文件中。然后通过 footer 的 index，找到这条数据在这个文件的那个 RowGroup 中，最终再加载 整个 RowGroup 的数据，找到这条数据。\n即使是访问一条数据，也需要访问加载整个 RowGroup，显然是不够高效的。\nLance 如何支持随机访问 Index!!!\n在 Lance 中，每一条数据都会分配一个 row_id 列，这是个递增的系统列。然后 Lance 会记录下每个文件包含的 row_id，比如 \\[1, 42, 3\\] 表示的是这个文件的第一条数据，第二条数据，第三条数据的 row_id 分别是 1，42，3。\n这样就可以知道某个 row_id 是在哪个文件的第几条数据，对于每一列，lance 文件的 footer 都记录了\n每个数据块（一列的数据会组织成多个数据块）所在文件的 offset\n每个数据块中数据的数量\n通过 每个数据块中数据的数量 可以知道这条数据在哪个数据块中，通过数据块的 offset 信息，定位到该数据块。\n接下来就变成了访问该数据块的第 i 条数据，为了快速访问到这条数据，数据块本身会记录 index 信息，然后就可以定位到这一条数据。\n注：关于 Index 信息，这里多说几句，如果不压缩的话，Lance 本身是以 arrow 格式来存储的，即会做对齐。\n对于 int 类型，总是使用 4 个字节来存储，所以如果要访问第 i 条数据，直接访问 i * 4 这个offset 的数据即可。所以 lance 对于 int 类型，并没有存额外的 index 信息，是通过 4 字节对齐做的。\n对于 string 变长类型，lance 会存储一个数组来记录每条数据的偏移量，这样也可以通过呢这个偏移量来直接定位到这一条数据\n但是如果压缩的话，其实还是需要将整个数据块读出来进行解压。\nParquet 不能很好支持超大列 超大列指的是这一列的每个 value 的 的 size 都很大，这在 AI 场景中特别常见，比如有一列直接来存储 embedding 的向量，甚至图像本身。\n为什么 Parquet 不能很好支持超大列 在 Parquet 文件中，有 RowGroup（行组）的概念，即先数据水平切分为若干个行组，然后再在 RowGroup 里面按列存放数据。数据读取的粒度也是以 RowGroup 来进行读取。这在某些列是超大列的情况下，会陷入如下的两难境地：\n正常的 Row Group size，如下所示： 我们还是希望和以前一样，采用正常的 RowGroup，但是我们会发现，一个 Row Group 存储的数据更小了，对于窄列来说，需要读取更多的 Row Group了，更多次 IO 了，会存在大量小 IO，读取性能并不是很好。\n用一个很大的 RowGroup，如下所示： 另外一种方式我用更大的 RowGroup，保证RowGroup能存储更多的数据。但是这样的问题也很大，我们需要在内存中 buffer 更多的数据才能写成 RowGroup，并且读数据的时候，并发数也变小。\nLance 如何支持超大列 No RowGroup!!!\nLance 直接把 RowGroup 干掉了，Lance 数据文件结构如下所示：\nLance 不再有 RowGroup 的概念了，而是引入一个 DataPage 的概念。DataPage 存储的是某一列的数据，写某一列数据的时候，攒满一定 bytes 数后就 flush 成 Data Page，以此类推。不同的列可以有不同数量的 Data Page，超大列会有更多的 DataPage。\n虽然 Lance 没有 RowGroup 了，但是也 Parquet 类似，也还是会有 footer 来记录列的 metadata，帮助我们快速定位到 Data Page。\nParquet 不能很好支持大宽表 大宽表指的是一个表有大量（上万）列，在 AI 场景下，大宽表是非常常见的。\n为什么 Parquet 不能很好支持超大列 虽然 Parquet 作为一种列存格式文件，可以有些地支持列裁剪。但是即使对于只访问一列，也需要加载文件 footer 所有列的 metadata，这在列的数量很多的情况下也是个开销，大致如下所示：\n核心原因是 Parquet 是按 RowGroup 来组织 metadata 的，如下图所示： 图来自于： https://parquet.apache.org/docs/file-format/metadata/\nLance 如何支持 Lance 没有 Rowgroup 的概念，各列单独存储统计信息和所在文件的 pos，这样要访问某一列直接读对应列的信息即可。我们看一下 Lance 的文件 layout 就可以理解了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 // ├──────────────────────────────────┤ // | Data Pages | // | Data Buffer 0* | // | ... | // | Data Buffer BN* | // ├──────────────────────────────────┤ // | Column Metadatas | // | |A| Column 0 Metadata* | // | Column 1 Metadata* | // | ... | // | Column CN Metadata* | // ├──────────────────────────────────┤ // | Column Metadata Offset Table | // | |B| Column 0 Metadata Position* | // | Column 0 Metadata Size | // | ... | // | Column CN Metadata Position | // | Column CN Metadata Size | // ├──────────────────────────────────┤ // | Global Buffers Offset Table | // | |C| Global Buffer 0 Position* | // | Global Buffer 0 Size | // | ... | // | Global Buffer GN Position | // | Global Buffer GN Size | // ├──────────────────────────────────┤ // | Footer | // | A u64: Offset to column meta 0 | // | B u64: Offset to CMO table | // | C u64: Offset to GBO table | // | u32: Number of global bufs | // | u32: Number of columns | // | u16: Major version | // | u16: Minor version | // | \u0026#34;LANC\u0026#34; | // ├──────────────────────────────────┤ 假如要读第 i 列的话，首先通过文件的 footer 定位到第 i 列的 Metadata，然后通过这个第 i 列的 metadata 得到该列数据对应数据块的信息。\n总结 相比于 Parquet，Lance 格式可以算是极致的“列裁剪”了，在 AI 领域确实有其独特的价值，但是也是个 trade offset，可以想见，其在传统的 OLAP 分析领域上还是没法和 Parquet 比的，比如 Parquet 的高压缩率，各种 fiter pushdown，大部分列读取等\nLance支持多模态数据的方式是直接在数据文件中存储多模态数据，相比于 Gravitino 的存储一个图片路径的方式，我更喜欢 Lance 这种可以直接存图片本身的方式。\nLance 格式内置了索引以支持向量检索在 Agent 时代还是很有用的\n我觉得最重要的一点是 Lance 很好地对接了 AI 生态，如 Pytorch，Tensorflow，Huggingface 等，几行代码就可以让 Lance 的文件作为 Pytorch 模型的训练集。并且 Lance 的 co-founder 也是 Pandas 的核心贡献者，个人还是看好 Lance 成为未来的一个 AI 文件格式的事实标准\n参考链接 https://lancedb.github.io/lance/format.html#\nhttps://blog.lancedb.com/lance-v2/\n","permalink":"https://luoyuxia.github.io/posts/lance---ai%E6%97%B6%E4%BB%A3%E7%9A%84%E6%95%B0%E6%8D%AE%E6%A0%BC%E5%BC%8F%E6%A0%87%E5%87%86/","summary":"Lance是一种专为机器学习和AI优化的列式数据格式，通过摒弃RowGroup、引入DataPage及内置索引，解决Parquet在随机访问、超大列、大宽表支持上的不足，更好适配AI工作负载并对接主流AI生态。","title":"Lance - AI时代的数据格式标准？"},{"content":"这几天去北京参加了 QCon 大会，主题是大模型正在重新定义软件，现在我满脑子都是 AI Agent。\n本着“参会不总结，等于没总结” 的原则，总结一下这几天的一些 talk 吧\n从代码到文明：AI 安全的技术深渊与治理边界 聊了一下 大模型的安全问题，比如通过提示词注入，不安全模型加载和文件处理，不安全的反序列化与处理（分布式训练/模型推理序列化机制）。关于不安全的反序列化有一个例子比较有意思，可以通过提示词让 AI 使用一种编解码方式（甚至是一种之前完成不存在的编解码方式）。\n然后讲了一下大模型在腾讯内部网络攻防的应用：\n漏洞挖掘，大模型辅助进行二进制逆向分析\n渗透过程，用大模型辅助自动化部分渗透路径\n大模型辅助钓鱼攻击\n最后提了一个 AI 应用隐私保护的一个流程：\n终端小模型对用户的输入进行脱敏信息\n云端大模型进行处理，返回处理结果\n终端小模型对处理结果反脱敏，返回给用户\n生成式 AI 驱动的软件开发生产力变革 主要就是讲了一下 Amazon Q Developer CLI，可以理解为一个 AI code agent，提示一个任务，Q Developer CLI 帮你把代码写好。甚至还提了一个例子，Amazon Q Developer CLI 说他们有一个 fetaure，完全由一个不懂 Rust 人通过 Q Developer CLI开发。看起来自己要失业了。\n这个 Amazon Q Developer CLI 其实不是由一个 agent 构成，由多个单一功能的 agent组成的，比如输入 /dev 调起写正式代码的 agent，输入 /test 调起写测试代码的 agent。\n我比较认可他的一个观点是 ，AI 帮助我们开发，开发者去学习背后底层技术，花更多时间提示自己的认知。\n不过我还是很焦虑，因为我并不想花时间提升自己的认知\u0026hellip;.\nAl Vision Shape the Future 微软的一个分享，听完这个 talk，感慨 AI Agent 居然已经在国外发展这么快了吗。\n微软云平台提供了多个不同的 agent，分别对应完成不同的 sub-task，用户在微软的平台拖拉拽多个不同 sub-task 的agent协助来完成task。\n以一个企业为例，企业的每个部门都可以抽象成一个 agnet，如人力资源，IT等。对于员工办理入职，有个 plan agent 去生成 plan，生成执行计划，然后多个 agent 互相通过聊天的方式进行交互。不过这期间还是需要人介入，来选择要不要执行这些步骤。\n另外举了一个他们的 use case，用户要买一个音箱， agent 自己去操作web浏览器，自动完成一系列操作。\nAgent 元年，关于知识管理的新思考 有一个观点我挺认同的，想让 Agent 帮你打工并不容易，比如写周报，但是你需要告诉 Agent 你这周做了什么，Agent 需要更加了解你。\n于是他们搞了个 Remio，一站式管理你的文件，网页和文件自动入库，了解你的一切信息。\nFluss 湖流一体：Lakehouse 架构实时化演进 没什么好多说的，因为是我自己的一个分享。小米的老师会后找我要了份 PPT，说要带回去在他们内部分享下。\nStarRocks x Iceberg: 探索Lakehouse架构极致查询性能 OLAP 也太卷了吧。\nStarRocks的老师 分享了一下他们在 Iceberg 上做的优化，不少干货。\nMetata 解析开销大，Plan 耗时长\n分布式 Metdata Plan，把元数据的解析当作 starrocks的一个query，发给 BE 统计信息不足导致 plan 恶化\n查询触发统计信息收集 冷数据 IO 访问开销大\n针对 AWS client 进行优化，poco client，poco 连接池 Cache 不够 smart，访问频次不高，但是延迟敏感\nIO 自适应，磁盘（local cache）达到瓶颈，远端访问也许更快 Cache miss 引起抖动\nCache 共享，新节点的加入，从其他节点访问cache 数据 字符串执行效率低，难以向量化，内存占用高，传递开销大\n低基数字符串优化，类似字典压缩；数据分析场景，80%是低基数字符串 Parquet 文件解析开销大\n高效谓词优先（优先执行能过滤更多数据的 predicate ），Filter 下推到 Decoder 湖上物化视图\n自动查询改写 从碎片到统一：如何用元数据湖解决多 Lakehouse 治理难题 主要是讲了一下 Gravitino，数据统一视图，统一访问和治理，没有太多技术细节。\n其实我一开始是比较好奇他们是怎么管理非结构化的数据，Gravitino 定义了一种 FileSet 类型，本身并不存储非结构化数据，只是记录一个文件引用，如下所示：\n1 2 3 4 5 FileSet { name: string, storgeLocation: string, type: Type } 关于人工智能大模型的几点思考 郑纬民院士讲了构建人工智能大模型的关键技术点和挑战，然后说了一下他们搞得一些技术或者系统如何来解决这些挑战。我觉得还是很有参考价值的。\n大模型训练需要的数据量大，小文件多，元数据管理难\n他们搞的 Super Fs： 分布式管理元数据，并且目录元数据和文件元数据分开 数据预处理\n他们搞了个叫诸葛弩的东西，看起来是 C++写的一个 Spark，兼容PySpark编程接口，提供基于 C++ RDD 编程接口\n模型训练 十万张卡训练模型，平均每个小时会发生一次硬件，软件错误，所以训练过程需要对模型参数记录到检查点文件，避免发生故障后重新训练。大模型检查点文件的读写对存储系统提出了更高的要求，于是他们搞了个分布式检查点，数据被均匀分布到所有参与训练的检查点 从指令到 Agent：基于大语言模型构建智能编程助手 讲了一下字节跳动搞的 code agent。印象比较深的是他们如何管理多轮对话与记忆。与 agent 交互的时候，随着交互轮数变多，需要的context 也就更大，消耗的token 也就越多，所以需要对 context 进行压缩。他们试过用 大语言模型对信息进行摘要，但是大模型很贵，于是他们最终选择通过规则和策略决定那些信息决定面小，然后将这些信息丢失掉，成本也可控。\nAgentic RAG 的现在与未来：从使用工具到重构知识系统 强调了对于 Agent，上下文是关键。举了个自己使用 Code Copilot 的感受，使用 Github 的 Copilot，跨多个文件进行 code 的效果不是很好，但是使用 Cursor，跨多个文件进行 code 效果很好，原因Cursor有更多的 context，可以捕获到整个项目的context。\n我觉得他提到一个 Agentic RAG 系统架构还是很有参考价值的：\n有一个 Agent 控制器，负责规划，行动，反思\n有一个检索模块，支持语义检索，多轮动态检索\n有一个工具调用模块，调用外部工具来执行任务，搜索，数据库操作等\n状态管理 \u0026amp; 日志模块，追踪整个 Agent 的过程，思考过程，记笔记（用户的这个问题通过什么方式解决了，Agent 在检索过程中缺少了什么数据）等对 Agent 进行迭代，反馈\n云端 AI Agents 系统开发的探索与实践 核心还是要有多个 AI Agent 之间的协同，编排。有一个 Super Agent 进行 Plan，Sub Agent 并行化进行处理，Super Agent 再进行归纳总结。\n面向 AI Agents 的高性能数据基座：架构和工程实践 在 AI Agent 时代。一个统一的支持多模态的，低延迟的数据存储很重要。因为 AI agent 需要多轮交互，每一轮交互都必须快速响应，并且，AI 去检索知识总归是需要去不同类型的数据库进行检索，图数据库，文档数据库，向量数据库等。\n于是他们想做的中间（上层是计算引擎，下层是物理存储）的这一层数据 Cache 以支持高速访问， Cache 兼容上层计算引擎 APi，如 Mongo API 等。\n我觉得这位老师讲的思路很清晰，不过总感觉有点“虎头蛇尾”，讲了这么多统一的数据存储，最后只是展示了一些他们同时做的 KV 存储 EloqKV，说和 RocksDB 比性能更好。我问了一下性能好的原因，他说他们分析了一下，是因为他们用了 协程（减少了线程上下文切换的开销） + 异步 IO（IO Uring，SPDK）。\nData Warebase\u0026ndash; 一体化数据平台的云原生实践 忘了是前多少任老板的一个分享了，主要是分享他们公司做的 ProtonBase，一个一体化的分布式数据库，支持OLAP，OLTP，向量检索等。\n我主要记了三点值得关注下\n如何水平扩展\n水平扩展就是通过分shard 来解决，但是有 hash 的方式分shard，有range 的方式分shard，他们选择来 range 的方式。理由是从用户的角度看，range 分片范围查询效率高，扩缩容可自动进行（hash 分片需要预先定义到分多少个片，并且要做大量数据的迁移），虽然工程实现上要复杂点 如何做存算分离\n传统的方式就是直接写对象存储，但是说写对象存储不能满足他们的高并发实时写入（s3天然不支持低延迟，频繁调用）。所以他们抽象出来一个统一的存储层，使用高速本地盘或云盘，内置 raft 协议进行高可靠。下面还有个对象存储来进行冷存以节省成本。看起来像是 Pangu + OSS ？毕竟副总裁是前盘古团队架构负责人（雾 如何做 HATP 架构\n传统方式就是 OLTP（行存） 一套存储，OLAP（列存） 一套存储；然后自动从 OLTP 到 OLAP。他们搞了个混合存储只要一套存储同时满足 OLTP 和 OLAP 的需求。没讲太多细节，我问了下是咋做到的，直观看起来一套存储满足两种需求不太 make sense。前多少任老板说是它们内部搞了一种文件格式，可以满足两者的需求，具体文件格式啥样没讲太多。不过个人感觉可能还是列存本身，只不过可能加了索引什么的可以满足高性能实时写入，高性能点查吧。我估计真正应对 OLTP 场景，数据频繁单行单行修改，并且也要保证读/写毫秒延迟也够呛。 个人总结 AI Agent 应该是目前最火爆的方向了，我觉得也是真正让大模型更近一步落地的方向。相比训练出一个通用的大而全的 Agent，训练出多个专攻特定领域的 Agent 更切实际并且效果要更好。多 Agent 之间的协作，编排是值得继续关注的方向\nContext 对于 Agent 来说非常重要，如果高效管理 Context 是个值得关注的方向\n虽然大家都在说多模态数据，但其实目前貌似我也没有看到太多关于如何管理多模态数据的案例分享\n随着 AI 对数据有不同的访问需求，统一的元数据层是比较明确的一个趋势。虽然也有在提统一的存储系统，不过我还是觉得底层存储还是比较难统一，因为不同存储确实有他自己擅长的领域\n","permalink":"https://luoyuxia.github.io/posts/qcon-%E5%8C%97%E4%BA%AC%E5%8F%82%E4%BC%9A%E6%80%BB%E7%BB%93/","summary":"QCon北京大会大模型正在重新定义软件参会总结","title":"QCon 北京参会总结"},{"content":"AutoMQ 是一款开源的，存算分离架构的 Kafka 发行版，目前在 Github 上有 4.2k star。AutoMQ 是基于 Kafka 代码改的，只改了底层存储的代码，所以也天然兼容 Kafka 协议。AutoMQ 的核心亮点就是存算分离，本文也主要介绍 AutoMQ 存算分离的实现。\n为什么需要存算分离 要理解 AutoMQ 的设计，我们需要理解为什么要存算分离这个问题。虽然在这个云时代，存算分离似乎已经“烂大街”了，动不动就某某产品号称存算分离，成本节省 10x，无脑 buyin。但是冷静下来，我们还是要明白存算分离对 Kafka 来说有什么好处。\nKafka 是存算一体的架构，使用机器的本地磁盘保存数据。而存算分离指的是：不再使用本地磁盘保存数据，而是使用共享的对象存储来保存数据。基于这个解释，存算分离的 Kafka 则有如下好处：\n成本节省 相比于本地磁盘，对象存储本身是非常廉价的。移除了本地磁盘，成本可以大幅削减\n计算资源单独扩容 这里说的计算资源指的是 CPU 资源。\n首先需要明确一点的是，对于云上本地盘 ECS来说，本地磁盘的存储和 CPU资源是绑定的，要升级本地磁盘的存储量，则 CPU 资源也需要相应升级。\nKafka 存算一体的架构下，数据是保存在 Kafka 集群 Broker 的本地磁盘上。如果本地磁盘满了的话，则必须对 Kafka 集群进行扩容\u0026ndash;增加 Kafka 集群 Broker 的数量，或者升级本地磁盘的存储量，但这也就意味着增加了计算资源，然而此时可能计算资源并没有达到瓶颈，造成对计算资源的浪费。\n使用对象存储来保存数据的话，可以认为对象存储是个无限大的存储，不需要扩容，只需要在计算资源达到瓶颈的时候对计算资源进行扩容即可。可以使用云上计算型 ECS（不配备本地盘），直接对 CPU + 内存进行升配。\n秒级扩缩容 Kafka 存算一体的架构下，如果要进行集群的扩缩容，对分区进行 rebalance，则需要将数据从一台机器的本地磁盘中迁移（重新写入）到另外一台机器的本地磁盘，整个集群的 rebalance 通常需要耗费数小时的时间。\n而在存算分离的架构下，数据是存储在共享的对象存储上的，机器本身是不存储数据的，则避免了数据迁移的操作，基本上在秒级时间内都能完成。\n冷读不影响消息的写入 这个其实不是主要好处，算是存算分离顺便解决了 Kafka 的一个问题。\nKafka 存算一体的架构下，Kafka 写数据的时候，首先是写入 page cache 中，并异步地将数据写入磁盘。在冷读场景下，数据会从本地磁盘读出来，放到 page cache 中，对 page cache 造成污染，影响数据的实时写入。\n而在存算分离的架构下，则没有本地磁盘，也就没有 page change，数据是直写对象存储的，所以也不存在这个问题了。\nAutoMQ 如何做存算分离 其实存算分离说白了“不过就是”数据写入到对象存储，然后就结束了。\n但当然了，实际上并不那么简单，实现上必须要适配对象存储的特性才行。对象存储个人理解有如下特性：\n不支持 append\n写入延迟数百毫秒\n不喜欢 list\n而 AutoMQ 则是希望在上述对象存储的特性上，实现毫秒（小于10毫秒）级延迟的 Kafka。接下来解释一下 AutoMQ 是如何适配上面提到的特性的，并实现毫秒（小于10毫秒）级延迟的。\n基于对象存储实现 Kafka 的几个问题和 AutoMQ 的解决方案 不支持 Append 对象存储不支持 append，每次写入一批数据的时候都需要生成一个新的文件，这个时候这批数据才可见。要实现低延迟，生成新文件的频率也势必很高，而且考虑到一台 broker 上通常有数百上千个分区，如果对于每个分区，都生成一个新的文件，那么文件数就会很多，对象存储 API 的调用次数也会变多。\n为了解决这个问题，AutoMQ 的做法是将某段时间内写入到这台 broker 上的所有分区的数据都聚合起来，写入到若干个对象存储文件上。大概如下图所示：\n其中 Broker 中有 p1，p2，p3，p4 这四个分区，数据一开始都写到了内存，上传到对象存储的时候，将这些数据都聚合起来，根据设置的每个对象存储文件阈值，写到若干个对象存储文件中。如图所示：p1，p2和p3 的一部分数据写到了文件1，p3的另一部分数据和p4的数据写到了文件2。\n值得注意的是：AutoMQ 的一个对象存储文件可能会包含多个分区的数据，为了快速定位到某个分区的数据在该文件中的位置，该文件的末尾还包含一个 index block 来进行 index。如下图所示：\n文件末尾有个 footer 来指向 index 的起始位置，然后 index 分别指向 p1，p2，p3 数据所在的起始位置。这样 AutoMQ 如果要读这个文件的某个分区的数据的话，通过 footer 找到 index，然后再通过 index 找到对应的分区。\n考虑到消息队列读分区数据的连续性，老是去多个对象存储文件中读一小部分数据也不是个事。所以 AutoMQ 后台会进行 compaction，尽可能地将相同分区的数据都 compact 到同一个对象存储文件中，提高读的效率。如下图所示：\n在 compact 后，相同分区会倾向于在一个对象存储文件上，但是对于数据比较少的分区，不够多到可以组成单独的一个对象存储文件，依然还是会于其他分区的数据排列在一个对象存储文件上。\n写入延迟数百毫秒 对象存储的写入延迟较高，通常数十到数百毫秒。WrapStream 的方案是数据直接写到对象存储（s3） 当中，延迟在 600 ms 以上。\n而为了实现10毫秒内的延迟，直接写对象存储显然不现实。\n所以在 AutoMQ 的实现中，虽然数据最终也是会写到对象存储中 当中，但为了实现数毫秒的延迟，数据一开始是写到 WAL（Write ahead log） 中（一般选择云存储 EBS，提供亚毫秒级别延迟），写入到 WAL 中则认为数据被持久化了，给 Client 返回 ack，这通常是在几个毫秒内就完成。WAL 中的数据再被近实时地上传至 S3 存储。\n整体流程如下图所示：\n上面这张图基本上涵盖了 AutoMQ的核心思路：\nProducer 的数据一开始写入到 WAL 中，即云存储 EBS 中。云存储 EBS 内置 3 副本，所以写入到 EBS 中即认为数据写成功了。注意， WAL 并不会很大，它存的不是全量数据，存储的只是那部分还没有上传到对象存储 S3 中的数据\n然后数据被 put 到内存作为 deltaWALCache，如果 Consumer 读数据的时候命中了 deltaWALCache，则直接从 deltaWALCache 中读数据\ndeltaWALCache 满了的话就异步上传到对象存储 S3当中\nConsumer 在回追数据的场景下，读的那部分数据通常已经在对象存储 S3 当中，broker 会从 S3 中 fetch 数据放到自己的 BlockCache 中，Consumer 然后从 broker 的 BlockCache 中读。考虑到从对象存储 S3 中延迟比较高，AutoMQ 会采用 parallel read，prefetch read，batch read 等技术降低整体延迟。（注：这里图中的步骤4 有点问题，Consumer 并不是直接从 S3 读数据的，最终其实还是从 Broker 的 Message Cache 中读数据）\n不喜欢 list 为了知道某个分区有哪些数据文件，Kafka 的方式是 list 这个分区目录（Kafka 将相同分区的数据文件都放到同一个目录下），这样就知道了这个分区有哪些数据文件。\n但是基于对象存储的话，list 是非常废的，一定不能通过 list 目录的方式来知道分区有哪些数据文件。解决思路也比较简单，类似湖格式管理数据文件的方式，通过单独的对象存储文件来进行记录。\n总结 AutoMQ 直接在 Kafka的代码上改，不费什么力气就实现了 Kafka 协议兼容确实挺取巧的。不过没有用 Rust 重写，差评（雾\nAutoMQ 提出一开始直接写 WAL（通常是亚毫秒级别延迟的 EBS），然后再异步上传到对象存储的方式，在延迟和成本之间达到了一个 balacne，还是挺有吸引力的\n","permalink":"https://luoyuxia.github.io/posts/automq-%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E5%AD%98%E7%AE%97%E5%88%86%E7%A6%BB%E7%9A%84-kafka/","summary":"AutoMQ是一款开源的，存算分离架构的Kafka发行版，目前在Github上有4.2kstar。AutoMQ是基于Kafka代码改的，只改了底层存储的代码，所以也天然兼容Kafka协议。AutoMQ的核心亮点就是存算分离，本文也主要介绍AutoMQ存算分离的实现。","title":"AutoMQ 如何实现存算分离的 Kafka"},{"content":"摘要 Kafka 通过主从复制的协议对消息进行备份以实现高可靠的分布式系统，但是在如何正确地实现复制的协议中，Kafka作为一款公认的稳定可靠的分布式消息队列，也踩了不少坑。 本文首先深入介绍了 Kafka 的复制协议，然后引出了 Kafka 在这套复制协议上踩的若干坑和修复方案。 通过理解 Kafka 踩的坑和解决这些问题的思路和方案，希望可以对读者的在分布式系统设计上有所借鉴和启发。\nKafka复制协议 Overview Kafka 是一个分布式，高可靠的消息系统。为了实现高可靠的分布式系统，需要在多台机器上保存相同数据的副本，这样即使某一台机器挂了，其他机器则可以及时接管，提供这部分数据。而复制协议则是解决如何在多台机器上保存相同数据的副本，以及机器挂了其他机器如何接管的问题。\n数据的复制通常有两种策略，一种是主从复制，另一种是基于 Quorum 的复制，典型的如Paxos，Raft。这两种策略都需要选定一个主副本，客户所有的写请求都首先需要写入到主副本，然后主副本再将数据同步到从副本，副本同步成功了就给客户返回 ACK。\n但是什么时候认为副本同步成功了，这两种策略的行为则不一样，主从复制需要数据同步到所有副本，而基于 Quorum 的复制则只需要同步到大部分的副本（如果共有 n 个副本，则大部分副本数量为 n / 2 + 1）。\nKafka 采用的是主从复制的机制，在 Building a Replicated Logging System with Apache Kafka 这篇 2015年的论文也说了原因：“如果要容忍 F 个副本丢失，基于主从复制只需要 F + 1 个副本就可以了，但是基于 Quorum 的复制则需要 2F + 1 个副本，虽然基于 Quorum 的复制协议只需要同步到 F + 1 个副本，所以其复制延迟更低，也可以避免网络延迟/慢节点的影响，但是 Kakfa 通常是部署在相同的数据中心，网络延迟不大，我们认为节省副本数更重要”。\n根据 Kafka 的官方文档：Kafka 的复制协议的实现主要来源于论文 PacificA: Replication in Log-Based Distributed Storage Systems 的思路。不过在实现上稍微有些不同，Kafka 的复制协议可总结如下：\n数据首先写入到主副本，然后再由主副本同步到所有从副本，同步成功后给客户返回 ACK\n数据需要同步到所有从副本才认为成功，但是如果某一个从副本挂了，或者就是同步得很慢怎么办？\n如果还是等待的话，客户端写数据到收到 ACK之间的延迟就很大或完全写不进去了（在从副本挂了的情况下）。于是 Kafka 就引入了 ISR（In Sync Replica） 的概念了，ISR 是若干副本的集合，是所有副本的一个子集，数据只要同步到 ISR 集合中的这些副本中就认为写成功了。\n一开始 ISR 是所有的副本，但是如果某个副本 R1挂了，或者同步数据很慢，Kafka 将会将这个副本从 ISR 集合中剔除，这样数据不需要同步到这个副本 R1 也可以认为写成功了。对应地，有一个 参数 min.insync.replicas（Topic 级别的参数，默认为1） 来控制同步到多少个在 ISR 中的副本就认为写成功了。\n为了实现数据的高可靠，一个典型的配置是数据的副本数设置为3，min.insync.replicas 设置为2，这样数据同步到2个副本就认为写成功了。\n如果主副本挂了怎么办？ 如果主副本挂了，Kafka 的元数据控制中心 Controller 会从 ISR 中选择一个其他的副本来当作主副本，对外提供服务。值得一提的是，Controller 也必须是高可靠的，Kafka 之前是基于 Zookeeper（基于 Paxos协议），后面自己基于 Raft 协议实现了 KRaft Controller。 所以 Kafka 弄的这套复制协议不是自举的，anyway 都需要额外的一个复制协议来实现元数据的高可靠。\n深入理解Kafka复制协议 接下来我们来深入了解一下 Kafka 的复制协议，这对我们后面理解 Kafka 在复制协议上踩过的坑至关重要。\nKafka 架构图 Kafka 为了对消息分类，引入了 Topic 的概念，类似数据库中表的概念。\n为了提高系统的吞吐和可扩展性，在 Topic 的基础上，引入了 Partition（分区），一个 Topic 会被划分成多个 Partition，一个 Topic 的多个 Partition 会被放到多个 Broker 节点中。\n为了服务的高可靠，引入了 Replica（副本）的概念，一个 Partition 包含多个 Replica，Replica 是一主多从的关系，有一个 Leader Replica 和 若干个 Follower Replica，Replica 分别在不同的 Broker 节点上。Leader Replica 负责读/写请求，Follower Replica只负责同步数据，Follower Replica 会主动向 Leader Replica 发起 fetch 请求从 Leader Replica 读数据，写入到本地存储中。\n同时，由一个 Controller 来控制整个 Kafka 集群，做一些协调工作，比如 Leader Replica 挂了的话，Controller会从其他 Follower Replica 中选取一个作为新 Leader，对外提供服务。\n整体架构如下图所示：\nKafka 日志复制流程 首先我们需要理解两个非常重要的概念，LEO（Log End Offset），HW（High Watermark）。\nLEO 表示分区中的下一个被写入消息的偏移量（offset），用于记录 Leader Replica 和 Follower Replica 之间的数据同步进度，正常情况下，Leader Replica 的 LEO 总是要大于等于Follower Replica。\nHW 是 ISR （In Sync Replica）中最小的 LEO，其表示 ISR 中的 Replica 都已经复制 HW 之前的消息了，即这些消息都认为是已经写成功的。消费者可以且只能消费到 HW 之前的数据。\n注：之前我们提到过，数据只要写到 ISR 集合中的 Replica 中，就认为消息写成功了。ISR 集合一开始是全部 Replica ，如果有 Replica 挂了，该Replica 就会从 ISR 集合中剔除；比如一开始 Partition A 分配了 三个Replica {0, 1, 2}，其 ISR 也为 {0, 1, 2}，但 Replica 1 挂了的话，其 ISR 变为 {0, 2}。如果之后 Replica 1 恢复回来，且追上了 Leader Replica 的话，其 ISR 就将变为{0, 1, 2} 了。\n下面来理解一下 Kafka 日志复制流程和对应的 LEO 和 HW 更新流程，假设有三个 Replica：\n初始状态，三个 Replica 各有 m0 和 m1 两条消息，LEO 都是 2，表示下一条要写入的消息的偏移量（offset），m0 和 m1 消息的offset 分别是 0 和 1。HW 也都是 2，表示 Leader Replica 中的所有消息已经全部同步到 Follower Replica 中，消费者可以消费 m0和 m1这两条消息。如下图所示： 接下来，生产者向 Leader Replica 中发送两条消息，m2，m3，此时 Leader Replica 的 LEO 的值增加2，变成4。但是由于 Follower Replica 还没同步到者两条数据，所以 HW 和 Follower Replica 的 LEO 的值都没有发生变化。消费者还是只能消费 m0和 m1这两条消息 Followe1 和 Follower2 都向 Leader replica 拉取数据，同步到自己本地，但是同步速率不同，Follower1 已经同步到 m2 和 m3，但是 Follower2 只同步到了 m2。此时 Leader 的 LEO 和 Follower1 的 LEO 都是4，但是 Follower2 的 LEO 是3。同时 HW 代表 Replica 中最小的 LEO，所以还是 3，因为 Follower2 的 LEO 最小，为3。消费者可以消费 m0，m1，m2这三条消息。 Follower2 也同步 m3 到本地了，此时所有 replica 的 LEO 都是 4，并且 HW 也都更新到4了。此时消费者可以消费到 m0，m1，m2，m3这四条消息。 至此，Kafka 的复制协议的基本流程就讲完了，但在实际的实现中，并没那么简单。Kafka 也是对自己的复制协议打了很多的 Patch ，接下来我们来看看 Kafka 在复制协议上踩的坑以及提出的解决方案。\nKafka复制协议踩过的坑 KIP-101 - Alter Replication Protocol to use Leader Epoch rather than High Watermark for Truncation 问题 上述介绍的复制协议可能会出现数据丢失或者数据发散的情况，考虑如下两个场景：\nCase1 数据丢失 Kafka 的复制协议有两个阶段：\n第一阶段，follower 向 leader fetch 消息，假设 fetch 到了消息 m2，并append 到本地。follower 在下一轮的 fetch 请求中，follower 会告诉 leader 自己收到了消息 m2，Leader 就可以更新 HW（High Watermark）。 Leader 会在之后 follower 的 fetch 请求的 response 中把 HW 带上，这样 follower 就知道 HW 是多少了。\n第二阶段：follower 初始化的时候，follower 会将自己本地的消息截断到它自己记录的 HW 中，然后再从 leader fetch 消息。但是这可能会导致一些已经被认为写成功（返回给客户端 ack）的消息被截断了，造成数据的丢失。\n假设我们有两个 broker：A \u0026amp; B，A 是 follower，B 是 leader。\n一开始 A 从 B 中 fetch 到了消息 m2，然后发起下一轮 fetch，告诉 B m2 已经被同步了，然后 B更新自己的 HW 为 2 注意此时 A 并不知道 HW 被更新为 2 了，需要在A 发起下一轮的fetch 请求，B 才会告诉 A 其HW 为2，这个时候 A 才能更新自己的 HW 为 2.\nBroker A 重启了，它把自己本地的消息truncate 到 HW 1 了，注意：这个时候 m2 在 A 中就被删掉了 然后 A 从 Leader B fetch 数据，但是假设 Leader B 也挂了，此时 A 就是新的 Leader 的，但是 m2 却丢失了 看起来原因就是 follower 的 HW 和 leader的 HW 更新步率不一致，follower 的 HW 需要再一轮 fetch 请求才可以更新它的 HW。一个直接的办法是 follower 更新 HW 后，leader 再去更新 HW，但这样就会让 leader HW 需要再多等一轮 fetch 才能被更新，会增加复制协议的延迟。并且也依然不能解决下面的数据发散的问题。\nCase2 数据发散 首先有个背景是，Kafka 的 Broker 将数据写到本地，实际上只是写到 page cache 中，所以如果 Broker 所在的机器直接挂了，这部分 page cache 中的数据在该 Broker 上就是丢失的。考虑如下的 case：\n一开始 A 是 leader，写了m1，m2 两条数据。follower B 也fetch 了 m1，m2 两条数据，于是 A 的 HW 更新成2。但是 follower B 写的 m2 这条数据并没有 flush 到磁盘。假设 A，B 都挂了，此时 B 同步的 m2 这条数据丢失了。 此时 B 恢复过来了，成为了 Leader，并且接受了 m3 这条消息 A 也恢复过来了，truncate 数据到 HW 2，所以不会truncate掉 offset 为1 的 m2 消息。但是 Leader 的 offset 为1 的消息却是 m3。这样消息在不同 replica 之间就发散了，数据就不一致了。 解决方案 核心问题就是 Follower 不应该直接根据自己记录的 HW 来将消息进行截断，而是应该和 Leader 进行交互来知道自己应该 truncate 哪些数据。Leader 直接返回 HW 可以解决 case 1，但是解决不了 case 2，case2 的问题在于在 leader 会发生切换的情况下， Follwer 无法知道相同 offset 的一条 message 是不是相同的 message，也就无法进行将相同 offset 下与 Leader 不同的数据 truncate 掉\n于是，Kafka 引入了一个 partition leader epoch 的概念来标识一次 leader 任期，每发生一次 leader 的切换，该 partition 的 leader epoch 就会加一，相同 leader epoch 下 append 的 相同 offset 的 message 也就是相同的。\n每一个 replica 维护一个 \\[leaderEpoch -\u003e StartOffset\\] 的映射来标记每个leader 任期的起始消息的offset，有了这个，Replica 就可以知道某个 epoch 下 append 的最后一条消息的log offset。 follower 恢复的时候，带上自己记录的当前的 leaderEpoch 向 Leader 发送 OffsetForLeaderEpoch请求（虽然图片上是 名字是 LeaderEpochRequest，但是实际代码实现用的名字是 OffsetForLeaderEpoch） ，Leader 返回该 leaderEpoch下 append 的最后一条消息的 log offset ，follower truncate 到该 offset，然后再向 Leader 发送fetch 请求进行消息同步；\n考虑Case1 数据丢失的问题：\n一开始 Replica A 的 HW 是1，Replica B 的 HW 是2；他们记录的 leader epoch 和 log start offset 都是 0；\n然后 Broker A 重启了，A 带着自己记录的 leader epoch 0 向 B 发送 OffsetForLeaderEpoch 请求，B 收到该请求后，发现该 epoch 和自己记录的 leader epoch 0 一样，返回该 leader epoch 0 下 append 的消息的 end offset，即自己的 log end offset，返回 offset 2 给 follower\nfollower 收到 offset 2 后，不进行 truncate，数据不会丢失\n之后 Broker B 挂了，Replica A 成为 leader 后，leader epoch 从 0 变成 1，收到消息 m3，leader epoch 1对应的 log start offset 也变成了 2\n考虑Case2 数据发散的问题：\n一开始 Replica A 的 HW 是 2，Replica B 的 HW 是1；他们记录的 leader epoch 和 log start offset 都是 0；Broker A 和 Broker B 都挂了\nBroker B 重启了，成为 leader，收到消息m3，leader epoch 变成了 1，其对应的 log start offset 变成1\nBroker A 启动了，Replica A 成为 Follower，带着 epoch 0 向 Leader B 发送 LeadEpoch 请求，B返回 epoch 0 下 append 的消息的 end offset，即 log offset 1；A 于是 truncate 掉 offset \u0026gt;= 1 的消息，然后再从 Leader fetch offset = 1 的消息 m3。自己记录的 leader epoch 变为 1，其对应的 start offset 也变为1\nKIP-274: Fix log divergence between leader and follower after fast leader fail over 问题 提出 KIP-101 后，又发现 KIP-101 无法解决如下的 corner case：\nStep 1 假设有两个 Broker A 和 B；一开始A 是 leader，leader epoch = 1；append 了两批数据到这个 leader，第一批数据的 log offset 是\n\\[0, 10\\]，第二批数据的 log offset 是 \\[11, 20\\]。第一批数据被同步到了 Broker B，但是第二批数据没有；Broker 中的数据如下所示：\n注意：不同批次的message 用不同的颜色标识\nStep2 然后 Broker B 由于 preferred leader election 被选为 Leader 了，此时 A 和 B 都在 ISR 中，然后 B append 了一批数据，对应 log offset \\[11, n\\] 到本地中，现在Broker 中的数据如下所示：\nStep3 Broker A 成为 Follower，正常情况下，我们希望 Broker A truncate掉 offset 为 \\[11, 20\\] 的这批数据。但是此时，Broker B 下线了，Broker A 成为了 Leader，并且又 append 了一批数据，对应 offset \\[21, 30\\]。现在Broker 中的数据如下所示：\nStep4 然后 B 恢复过来，成为 Follower，于是带着 epoch 2 向 Broker A 发送 OffsetForLeaderEpoch 请求， Broker A 返回21（大于 epoch 2 的 leader epoch 对应的start offset，在这里即为21）。\n如果 n \u0026lt;= 20，它不会进行 truncate，于是会从 n 开始向 leader fetch 数据。但此时数据已经发散了 如下图所示：\n如果 n \u0026gt; 20，它会 truncate 到 offset 21，但是由于 offset 21 是这一批数据的中间部分，于是会继续truncate，直到这一批数据的起始位点，即 11，此时 Broker B 中的数据只有 \\[0, 10\\]了，然后从 offset 11 开始向 Leader A fetch 数据，数据不再发散，保持了一致性，如下图所示： 解决方案 其实核心的问题就是 Broker A 只有 epoch 1 和 3，它无法告诉 B 在 leader epoch 2 下append 的最后一条数据的 offset。在这种情况下，Broker B 应该用 leader epoch 1 去向 Broker A 发送 OffsetForLeaderEpoch 请求，然后 truncate掉自己在 epoch 2 下append 的数据。\n所以 Kafka 提出修改 Follower 的整个恢复流程如下所示：\nFollower 恢复的时候，发送 OffsetForLeaderEpoch 请求，并带上自己记录的最新的 leader epoch 给 leader\nLeader 给 Follower 回复一个自己记录的 小于或等于 OffsetForLeaderEpoch 请求中的 leader epoch 的最大的一个 LeaderEpoch 和其对应的 end pffset\n如果 Follower 自己也记录了这个 Leader 回复的这个 LeaderEpoch，跳到第4步，否则\nFollower truncate 掉所有 epoch 大于 LeaderEpoch 的消息\nFollower 再用一个小于 LeaderEpoch 最大的一个 epoch 向 Leader 发送 OffsetForLeaderEpoch 请求\n重复步骤2和3\nFollower truncate 到 end offset 对应的消息\nFollower 继续向 Leader fetch数据\n于是就可以解决上面提到的问题了，考虑上问题的 Step3：\nBroker B 成为 Follower 后，带着 leader epoch 2 发送 OffsetForLeaderEpoch 给 Broker A，Broker A 找到自己记录的最大的一个 epoch \u0026lt;= 2的 epoch，和其对应的 end offset: {leader_epoch = 1, end offset =21}，返回给 Broker B。Broker B truncate 掉所有大于 leader_epoch = 1 的数据，在这里就是 \\[m11, n\\]，然后再从 offset 11 开始从 Broker A fetch，这样 Broker A 和 Broker B 的数据就一致了。\nKIP-320: Allow fetchers to detect and handle log truncation 问题 考虑如下的 case，一个 Partition 有三个 Replica1，Replica2，Replica3。\n在 Leader epoch 0，Replica1 是 leader，消息的 end offset 为 50，Replica2 也复制到了 offset 50，但是 Replica3 只复制到了 offset 40 ，此时 high watermark 为 40\n此时 Replica2与 Controller 失去联系，但是 Replica2 还是可以从 Replica1 fetch 数据\n不管什么原因，Replica 3 被选为 Leader，leader epoch 为1，Replica1成为 Follower，truncate 消息到 offset 40\nReplica 3 写了20条消息，end offset 变为 60，Replica 1 也复制到了 offset 60\nReplica2继续从Replica1 fetch 数据，因为 Replica1不再是 leader 了，Replica1 返回 NOT_LEADER_FOR_PARTITION 的异常，Replica2 将重试\nReplica1 又变成 Leader 了，leader epoch 为2。Replica1从 offset 60 开始 append 消息。Replica2 继续重试向 Replica1 fetch 消息。Replica2 的当前 offset 是50，Replica2本来应该将消息 truncate 到位点 40，但是Replica2不知道Leader 变更了，所以不会truncate消息，而是继续从 offset 50 开始向 Leader fetch 数据，于是 Replica2 的offset 40 ～ 50 的消息就有问题了。\n另外，这个 KIP 还提到的一个问题是，虽然 Replica2 被 Controller 标记为 Offline，但是由于 Replica2 还是可以及时同步 Replica1 的数据，这样 Replica1 又会把 Replica2 加入 ISR 中了。但是 Replica2 又被 Controller 标记为 Offline 了，不会被 Controller 选为 Leader，破坏了 ISR 的语义\n解决方案 核心问题就是 Follower 不知道 Leader 发生变化了，解决方案也很直接，follower 发送 fetch request 的时候需要带上自己记录的 leader epoch，而 Leader 知道自己的 leader epoch，这样 Leader 就能告诉 follower leader 是不是发生变化了。\nLeader 和 Follower 侧的修改如下：\nLeader 侧 Leader 侧收到 fetch 请求后，会比对自己的 leader epoch 和 fetch 请求中带上的 leader epoch，只有当两个 epoch 一样，fetch 请求才会正常返回；如果 fetch 请求中的 leader epoch 小于自己的 leader epoch，返回 FENCED_LEADER_EPOCH 异常，如果 fetch 请求中的 leader epoch 大于自己的 leader epoch，则返回 UNKNOWN_LEADER_EPOCH。\nFollower Follower 向 Leader 发送 fetch 请求的时候会带上自己记录的 leader epoch，如果收到 FENCED_LEADER_EPOCH 异常，就不再从 Leader fetch 数据了，如果收到 UNKNOWN_LEADER_EPOCH 异常，就继续重试。\n我们来看，这个方案如何解决上面提到的问题，考虑step 6，Replica2 带着 leader epoch 0 向 leader Replica1 发送 fetch 请求，Replica1 发现自己的 leader epoch 大于 fetch 请求的 leader epoch 0，于是直接返回 FENCED_LEADER_EPOCH 异常，Replica2 就会停止fetch，不再作为 follower。\nKIP-380: Detect outdated control requests and bounced brokers using broker generation 问题 目前 Kafka 的 Controller 与 Broker 交互是通过发送如下的控制请求给 Broker 的：\nLeaderAndIsrRequest：某个 Partition 的 replica 被选为 leader 或 follower 了，通知 Broker 进行相应的操作，比如选为 follower 后，fetch 线程需要向 leader 所在的 Broker fetch 数据\nUpdateMetadataRequest：将集群的 metadata 信息同步给 Broker\nStopReplicaRequest：通知 Broker 停止 serving 或者删除某个 Partition 的 Replica\n并且 Controller 是通过监听 zookeeper 来感知Broker 的上线和下线的，Broker 上线的时候会在 zookeeper路径 /brokers/ids/znode 创建一个临时节点，Broker 是监听路径 /brokers/ids的变化来知道 Broker 的上线和下线的\n但是会出现以下几个问题：\nBroker1 向 controller 发送 ControlledShutdownRequest 告诉 controller 自己要 close 了，然后 controller 会发送一些控制请求，比如 StopReplicaRequest等给 Broker1，在这个时候 Broker1快速重启了，然后就接收到这些过期的（该 Broker 以前触发的）控制请求并进行处理，这样Broker1 就处于一个不正常的状态了\n分区 p1 和 p2 都在 broker1上，controller 发送 p1 的 LeaderAndIsrRequest 请求给 broker1，但是在broker1收到这个请求的时候，broker1 重启了，然后 broker1 收到了这个 LeaderAndIsrRequest 请求，这是 broker1 收到的第一个 LeaderAndIsrRequest 请求，它会重写 high watermark checkpoint 文件，由于 LeaderAndIsrRequest 请求只有p1，所以 p2 的 high watermark信息 就丢失了\n如果一个broker 1快速重启，controller 监听到了 /brokers/ids的变化，但是这个时候 broker 已经将自己注册到到了路径 /brokers/ids/1 中，controller 去list 路径 /brokers/ids，发现 broker 1还在，所以就会忽略 broker 1 的重启，也就不会发任何 request 给该 broker 1 让其进行初始化，导致 broker 1 永远无法初始化自己的 leader/follower Replica\n解决方案 核心原因就是没办法知道一个 broker 是没重启过还是经过了快速重启。于是 Kafka 引入一个 broker epoch 的概念， broker epoch 是唯一且递增的，每次 broker 重启，它的 broker epoch 就会增加，然后Controller 每次发送的控制消息中都会带上 该 broker epoch。这样：\n对于问题1和2，Broker发现控制消息中的 epoch 小于自己的 epoch，就直接 reject 这个消息。\n对于问题3，Controller 通过 epoch 的值来判断 broker是不是重启过，Controller 会比对自己 cache 中的 broker 的 epoch 值和该 broker 在 zookeeper中 记录的 epoch，如果 zookeeper 中记录的 epoch 更大，则表示是经过了快速重启，会把它当成一次正常的 broker shutdown 和启动来对待。\nKIP-903: Replicas with stale broker epoch should not be allowed to join the ISR 问题 考虑如下的 case：\n一开始一个 partition 有两个 replica A 和 B。A 是 leader，B 是 follower，且 ISR 为 {A}\nBroker B 追上了 A，然后 A 发送 AlterPartition 请求给 Controller 试图将 B 添加到 ISR 中\n在 AlterPartition 被 Controller 收到前，Broker B 直接挂了，注意此时在 page cache 中的数据还没 flush 下去\n这个时候 Broker B 恢复过来了，在 page cache 中的那部分数据丢失了\nController 知也收到了 AlterPartition 请求，并且知道 Broker B 是online 的，于是将 B 添加到 ISR 中，此时 ISR 为 {A, B}\n但是实际上 B 不应该添加到 ISR 中，因为它的数据丢失了。\n解决方案 核心问题是在 Broker B 挂了再恢复后，这个 AlterPartition 其实是一个过期的请求，这个请求是针对 重启前的 BrokerB的，而不是重启后的 Broker B。Controller 应该reject 这个过期的 AlterPartition 请求，但是目前 Controller 并没有办法知道这是个过期的请求。\n想法也很直接，在KIP-380后， Broker 已经有了 epoch了，AlterPartition 请求带上 broker 的 epoch 就可以了，Controller 发现 broker 当前的 epoch 比 AlterPartition 请求的 epoch 要大，就认为这是个过期的 AlterPartition 请求，可以直接 reject。\n方案的修改如下：\nFollower 侧 因为 AlterPartition 请求是 Leader 发出的，所以 Leader 需要知道要添加到 ISR 的 replica 所在的 broker 的 epoch，所以 Follower 在向 Leader 发送 fetch 请求的时候就会带上自己的 broker epoch\nLeader 侧\nLeader 需要记录下 fetch 请求带过来的 broker epoch\nLeader 在发送 AlterPartition 请求 的时候会带上 broker epoch\nController 侧\nController 将验证 AlterPartition 请求中要加入ISR 的 replica 所在的 broker 的 epoch 和元数据中的 broker 的epoch是否保持一致，如果不一致的话，就 reject 这次 AlterPartition 请求，并返回 INELIGIBLE_REPLICA 的异常\nKIP-966: Eligible Leader Replicas 比较惊讶的是这个问题在最新的 Kafka 版本中还是存在，预计要在 Kafka 4.0 修复。\n比较有意思的是，RedPanda（日志的复制是基于 Raft 协议，Raft 协议要求 flush 到磁盘）2013年5月写了篇博客说 Kafka 这种写到 page cache，不强制 flush 到磁盘的行为是有问题的，会导致数据的丢失。并且还提供了代码和demo流程来演示数据的丢失。\n然后 Kafka 社区就提了这个 KIP 来解决 RedPanda 说的这个问题了。\nPS：关于 Kafka 的复制协议为什么不需要强制 flush 到磁盘，而Raft 协议需要强制 flush 到磁盘的文章可以参考Why Apache Kafka doesn\u0026rsquo;t need fsync to be safe这篇文章，写得很好，我觉得解释得很清楚了。\n问题 首先有个背景是 Kafka 选 Leader 的时候只会（不考虑 unclean election 的情况）从 ISR 中选，当 ISR 集合中只有一个 Replica 的话，即使这个 Replica 挂了，也不会从 ISR 中移除掉。如果移除掉的话，就不知道选哪个作为 leader 了，因为 ISR 为空了。\n基于这样的背景，这个问题会在最后一个在 ISR 中的 replica 挂了的时候发生。考虑如下的 case：\n一开始一个 Partition 有三个 replica，ISR 为{0, 1, 2}，并且 min.isr 为 2\nT0 时刻，broker 0 与Kafka 集群失联了，从 ISR中剔除了，此时 ISR 为 {1, 2}\nT1 时刻，broker 1 也与Kafka 集群失联了，从 ISR中剔除了，此时 ISR 为 {2}\nT2 时刻，broker 2 直接挂了，在 page cache 中的数据还没flush 到磁盘，这部分在 page cache 中的数据丢了，但是 Kafka 并不会把 broker 2 从 ISR 剔除，因为 Kafka 要避免 ISR 为 空\nT3 时刻，broker 0 和 broker 1 恢复过来，但是 broker0和 broker1 都不在 ISR 中，controller 不会把他们选为 leader\nT4 时刻，broker 2 重启了，controller 把 broker 2 选为 leader。然后 broker 0 和 broker 1就开始 truncate + fetch 数据。于是这部分还没flush 到磁盘的数据就丢了\n解决 针对上面提到的 case，其实 broker 1 也可以被选为 leader，只要 T1时刻后 high watermark 不能被推进，不然 broker 1 的 high watermark为 10，broker 2 high watermark 为 12，消费者消费到了 offset 为 11 的消息。如果 broker 1 被选为 leader 后，消费者就再也消费不到 offset 为 11 的消息了。\n并且，我们需要知道一个 broker 是不是 unclean shutdown 的，避免选择一个 unclean shutdown 的 broker 作为 leader，unclean shutdown 指是还没有 flush page cache 的数据到磁盘就直接挂掉。\n如何知道一个 Broker 是不是 unclean shutdown 的\nKafka 通过在 server close 的时候写一个 CleanShutDownFile 来表示是不是clean 的shutdown，如果有这个文件，则表示是 clean 的shutdown，否则不是。\n如何保证T1时刻后时刻后 high watermark 不能被推进\nKafka 提出一个更严格的限制 high watermark 前进的规则：只有当 ISR 的 replica 数量大于或等于 min.insync.replica 的数量时，High watermark 才可以前进。\n之前的high watermark 前进规则是，只要 ISR 的中的 Replica 都复制到了某个 offset，high watermark 就可以前进到这个 offset。现在还需要 ISR 的 replica 数量满足 \u0026gt;= min.insync.replica 这个条件。\n这样 T1 时刻后，ISR为1，无法满足 \u0026gt;= min.insync.replica 这个条件，high watermark 也就不会前进了。\n如何让 Broker 1 也可以被选为 leader？\nKafka 提出来一个 eligible leader replica 的概念，简称 ELR；之前只能从 ISR 中选一个 leader，现在还能从 ELR 中选一个 leader 出来。Kafka 使用 ELR 来记录不在 ISR 中，但是 high watermark 之前的消息都有的replica。\n有了 ELR 这个概念后，broker 1 虽然不在 ISR 中，但会在 ELR 中，这样可以从 ELR 中把 broker1 选出来作为 leader。\n对于 ISR 和 ELR，Kafka 保证如下的行为：\nISR invariants：\nISR 可以为 empty 了，ISR 的行为和之前的行为保持一致。\nELR invariants\nELR 中的 replica 一定不在 ISR\nELR 一定有 high watermark 之前的消息\nELR 可能存在消息复制的滞后\n如果 ELR 不是空，那么 ISR 中 replica 的数量 就要小于 min.insync.replica\n除非有 unclean shutdown，否则 Contoller ELR + ISR 的 size 永远不会小于 min.insync.replica ，\n如果某个replica 有 unclean 的 shutdown，Controller 会把它从 ELR 中移除\n修改主要体现在 controller 侧，当 Broker 向 controller 提出修改 ISR 的 proposal 的时候，controller 进行如下的操作：\n如果提出的 ISR 大于或等于 min isr，controller 接受提出的 ISR， 并清空 ELR\n如果提出的 ISR小于 min isr 的话，controller 将\n维持目前 ELR 的replica\n把（当前 ISR - proposed ISR）添加到 ELR 中\n把同时在 ISR 和 ELR 中的 replica 从 ELR 中移除掉\n只有当提出的 ISR小于 min isr 的话， controller 才会把 replica 添加到 ELR 中，考虑到之前提出的限制，只有当 ISR 的 replica 数量大于或等于 min.insync.replica 的数量时，High watermark 才可以前进，这个时候 High watermark 也就不会前进了，并且 ELR 也一定包含high watermark 之前的消息，ELR 可以作为一个有效的 leader。\n考虑上面提到的例子：\nT1 时刻，ISR 变成 {2}，由于 ISR 的数量小于 min isr，Broker 1 加入到 ELR 中，ELR 变成 {1}\nT2 时刻，Broker2 挂了，ISR 为空，ELR变成 {1, 2}\nT3 时刻，broker 0 和 broker 1 恢复过来，Controller 从 ELR 中选择一个 online broker 作为 Leader，Broker 1 成为 leader ，ISR 为{1}，ELR 为 {2}\nT4 时刻，broker 2 重启了，但是 Controller 发现它是一个 unclean shutdown，将其从 ELR 中移除，此时 ELR 为空集合\nbroker 1 作为了 leader，不就出现上面的问题\n下面是一个例子来演示 ELR ， ISR 的变化和 Leader 的选举情况，假设有4 个 broker，min isr 为 3\nT0 时刻，所有 replica 都在线 T1 时刻，b3 和 b4 同步 消息比较慢，leader b1 提出将 b3 ，b4 剔除 ISR 中，Controller 将 ISR 修改为 {b1, b2}，ELR 也更新为 {b3, b4} T2 时刻，b3 追上来了，leader b1提出 将 b3 加入到 ISR 中，Controller 将 ISR 修改为 {b1, b2，b3}，此时 ISR 的数量大于等于 min isr 了，这个时候要将 ELR 清空。因为这个时候 high watermark 是可以前进的， high watermark 前进后，ELR 就不能被选为 leader 了 T3 时刻，b2 和 b3 掉线了，Controller 将 b2 和b3从 ISR 中移除，放到 ELR 中，此时 ISR 为 {b1}，ELR 为 {b2, b3} T4 时刻，b4追上来了， leader b1 提出 将 b4 加入到 ISR 中，此时 Controller 修改 ISR 为 {b1, b4}，ELR 为 {b2, b3} T5 时刻，b1，b4 掉线了，Controller 将 b1, b4 从 ISR {b1, b4}中移除，并把他们添加到 ELR ，并且此时 b3 恢复过来了，但是检测到是一个 unclean shutdown，应该从 ELR 中移除；于是 ISR 就变成 empty，ELR 为 {b1, b2, b4} T6 时刻，b1恢复过来了，但是检测到是一个 unclean shutdown，controller 将其从 ELR 中移除， b2 也恢复过来了。因为此时 ISR 为空，所以 controller 将 ELR 中的 b2 选为 leader，添加到 ISR 中，并从 ELR 中移除，这个时候 ELR 为 {b4} T7 时刻，b1，b3 都追上了 leader b2，leader b2提出 将 b1，b3 都加入到 ISR 中，此时 ISR 为 {b1, b2, b3}，由于 ISR 中 replica 的数量大于 min.isr，于是 ELR 也被清空了 总结 写到这，总算是尽我所能，把我所知道的 Kafka 复制协议踩过的坑讲完了，当然可能还有一些我不知道的坑，不过上面的内容应该也可以覆盖大部分 Kafka 在复制协议踩过的坑了。不得不说，Kafka 踩过的坑还真不少呀～\n不过虽然 Kafka 踩过不少坑，Kafka 在业界还是公认非常稳定可靠的分布式消息队列系统的。\n","permalink":"https://luoyuxia.github.io/posts/kafka-%E5%A4%8D%E5%88%B6%E5%8D%8F%E8%AE%AE%E4%B8%8D%E5%8F%AF%E4%B8%8D%E7%9F%A5%E7%9A%84%E6%8A%80%E6%9C%AF%E5%86%85%E5%B9%95---%E5%85%B3%E4%BA%8E-kafka-%E8%B8%A9%E8%BF%87%E7%9A%84%E5%9D%91/","summary":"Kafka通过主从复制的协议对消息进行备份以实现高可靠的分布式系统，但是在如何正确地实现复制的协议中，Kafka作为一款公认的稳定可靠的分布式消息队列，也踩了不少坑。本文首先深入介绍了Kafka的复制协议，然后引出了Kafka在这套复制协议上踩的若干坑和修复方案。通过理解Kafka踩的坑和解决这些问题的思路和方案，希望可以对读者的在分布式系统设计上有所借鉴和启发。","title":"Kafka 复制协议不可不知的技术内幕 - 关于 Kafka 踩过的坑"},{"content":"11月29日上午 The Past, Present, and Future of Apache Flink 今年（2024年）是Apache Flink 诞生 10 周年，王峰回顾了 Apache Flink 发展的10年，从一开始只是柏林工业大学的一个实验性项目，到在阿里巴巴大规模生产落地， 到成为流计算领域的事实标准，再到如今孵化出Paimon 构建流式湖仓。\n想到大学里面学的“新技术” Hadoop，Hive，HBase 现在的处境，不得不让人感慨，一个开源项目能坚持10年，并且直到现在还依然有勃勃生机也是不容易呀。技术的持续领先固然是一回事，不过主要还是有后面商业化公司的支撑，现在活得好好的开源项目基本上后面都有商业化公司支撑。\n最后预告了一下 Flink 2.0 的功能，存储分离，业务层流批一体（Materized Table），以及拥抱 AI 大模型。\nApache Flink 2.0: Streaming into the Future Flink 2.0 有很多吸引人的亮点，首先 Flink 2.0 的 Release Manager 宋辛童 介绍了 Flink 2.0 的 break changes，包含移除了一些 connector api，source/sink function，不一而足，也是解决了不少技术债，刚好趁着没有兼容性保证的 2.0大版本 发布一起解决掉。\n然后梅源介绍了 Flink 2.0 的 State 存算分离的 feature，我觉得算是 Flink 2.0 最吸引人的 feature了。现在大家都在谈云原生，存储分离，而 Flink 1.0 的 state 还是在本地，和本地磁盘强耦合，本地大 state 带来的成本，扩展性等问题也是饱受诟病和非议。在 Flink 2.0 版本，这个问题终于得到了解决。我下午还特地去听了 Flink 2.0 State 存算分离专门的 Talk，这里面的一些工作确实还挺有意思的。\n最后李麟也介绍了 Flink 2.0 中引入的 Materized Table，在SQL 层面上的流批一体。用户写一段 SQL，只需要设置 freshness，Flink 就能自动选择流模式和批模式了，不需要用户介入。我觉得未来的一些规划，比如自动 discovery Materized Table，通过 Materized Table 进行 SQL 优化还挺值得期待。\nPaimon 1.0: Unified Lake Format for Data + Al 李劲松介绍了 Paimon 这一年的发展，毕业成为 Apache 顶级项目，以及在各行各业大规模落地等。主要分享了一些用 Paimon 的动机，离线加速，提升数据的时效性，更快更便宜的廉价消息队列（分钟级别）等。 没有讲太多内容，然后就邀请了淘天，抖音，vivo 的工程师分享了一些他们基于 Paimon + Flink 的湖仓落地，更偏业务和技术细节多一点，但大体都是数据的时效性的提升等。\n本人有幸见证了 Paimoin 从 Flink Table Store 到如今的 Paimon\u0026ndash;中文社区最受欢迎的湖格式。 如果说以前 Flink 用户的态度是已经有了 Hudi 或者 Iceberg，为什么要用 Paimon。现在可能就是如果没有特别强的理由不用 Paimon，那就用 Paimon。\nFluss: Next-Gen Streaming Storage for Real-Time Analytics 对于离线走向实时化，Paimon 可以做到分钟级别，但在强实时的场景下，还是需要秒级存储。 于是伍翀分享了面向流分析场景全新研发的新一代流存储 Fluss。 一开始介绍传统流存储如 Kafka 在流分析场景面临的痛点问题，比如不支持更新，Flink 消费需要额外的去重算子，数据无法复用，难以修正数据，数据无法探查，数据回溯难，只能存储几天数据。\n然后就分享了 Fluss可以很好地解决上述提到的痛点问题。 此外Fluss ：\n采用列式存储：可以在服务端进行列裁剪，可以节省大量网络成本，流读的吞吐也随着裁剪的列成比例上升。 和数据湖的结合：Fluss 将数据湖作为 tiered storage，计算引擎可以直接在湖上的数据进行分析。和数据湖结合的 Feature有个专门的 Talk 分享。 通过 Fluss，Flink 的双流 Join 可以被改造成 Delta Join，减免 Large State，大规模 Join 更稳定，中间数据可查。 最后就是现场开源 Fluss 了，虽然我早已经知道了 Fluss 要在FFA 现场开源，但是还是有点期待，主要是也没怎么见过现场开源的，想看看是啥样的。可能大家都没见过，现场的反响还是很热烈的，坐我旁边一老哥也一脸震惊“不会就在现场开源吧”。开源后，旁边的同事想赶紧上去点个 Star，当个早期 Star 者，不过就卡一两分钟，再去点 Star 就已经排在 180名开外了。 Fluss 项目地址： https://github.com/alibaba/fluss\nAI时代下大数据技术未来路在何方？ 最后是圆桌会，探讨 AI 时代大数据技术未来路在何方，算是回答了在这个 AI 时代，我们大数据工程师或多或少会有的焦虑吧。大佬们一通聊，我也是听得云里雾里，反正最后我就记得了两点： AI 时代，大数据会更重要；以及 OpenAI 收购了 Rockset 也佐证大数据的重要性\n11月29日下午 Paimon 1.0: Unified Lake Format for Data + Al 主要介绍了 Paimon 1.0 的功能和特性，记录下几个自己觉得有意思的点\nObject table 非结构化的数据纳入结构化的湖格式的管理，比如 oss 上的一堆非结构化数据的文件，文件名，文件大小，文件类型，文件url 等就可以作为一个表的列；然后就可以在这个表上根据这些列进行过滤，定位到对应文件的url，最后机器学习工具就可以分析这个文件。避免了在对象存储上直接进行的 list 。\nPartition Finish markdown 分区结束后 若干分钟 内没有数据达到，将分区done 的标志写到元数据；然后下游就可以根据这个标志调度对应的 job\nBranch 和 Tag Branch：融合批和流的数据，一张相同的表具有批分支和流分支，为了数据的准确性，批分支需要回刷，查询也是以批分支为主。 比如批分支有dt = 11-20，dt=11-21，dt=11-22 分区，流分支有 dt=11-23 分区；等到数据回刷到主分支后， dt=11-23 就可以被清理掉了。主分支查不到的分区，去流分支查； Tag：没有分区的话，可以打一个 tag，tag 作为分区\niceberg兼容 思路比较直接，写Paimon snapshot 的时候也写一份 iceberg metadata；\n基于 Flink 进行增量批计算的探索与实践 流计算时效性高，但是成本较高。增量计算的思路就是将用户的 Query 以批的方式执行，但是不需要重新计算所有数据。每次执行的时候只计算增量部分，增量部分指的是 当前时刻的数据 - 上次执行的数据。执行的过程中需要记录下执行进度，方便下次调度的时候知道从哪个位点开始读增量。\n目前只支持了Append 流，Retact 流还不支持。Retact 流如果借助下游的表的 merge 能力，比如 Paimon 的 aggregate table， 还是比较好做的，不然可能还得把上次计算的结果作为状态，供下一次计算使用，有点复杂了。\n增量批计算还是比较依赖 Flink source的能力，比如只计算增量部分就需要 Source 来告诉 Flink 增量的这部分数据。与 Paimon 的对接是通过 TimeTravel 的方式来支持的\n流批一体向量化引擎Flex Native + 向量化的魔爪终究还是伸向了 Flink 。 蚂蚁的刘勇分析的流批一体向量化引擎 Flex，基于 Flink + Velox 。不过目前支持的功能还比较有限，只支持 Cal 算子和 Native Source/Sink，有状态的算子还不支持。\n主要讲了一个优化点就是 projection reorder，就是一个 projection，有引用字段（引用 input 的 f1，f2，f3），还有可向量化的 cal function。如果全部都作为一个 native cal 算子的话，需要额外将引用字段也进行行转列；通过projection reorder，生成两个算子，一个非native 算子，一个 native 算子，避免引用字段的行转列。\n并不是全链路的向量化，会有一定行转列，列转行的开销，看作业的效果，有部分作业的性能还出现了回退。\nFlink 2.0 存算分离状态存储 —— ForSt DB 这一场技术干货比较多，State 存算分离的好处不言而喻，state 不再受本地磁盘的限制，启动速度快（不需要下载 state 到本地）， checkpoint 轻量（不需要在checkpoint的时候上传大量文件）。 不过，state 完全放到远端，性能还要和 state 放到本地持平甚至更优，是有不少工作要做的； 框架层面\nState 访问异步化 毫无疑问，State 访问一定要异步化，不然就会一直卡在网络访问上了，cpu 基本干不了活。 不过异步化也带来一个问题，乱序问题如何解决，对于相同的 key，一定要这个 key 的 state 访问结束了才可以开始下一次对这个key 的处理。将对同一个 key 的操作进行划分，先进先出，处理完一个操作后再处理下一个操作。\n攒批 读写线程分离，读 + 写分别进行攒批；\nUnaligned checkpoint 的支持 之前的 Unaligned checkpoint 需要checkpoint in-flight data，在存算分离 state 下，还需要额外 checkpoint 当前尚未开始的 state request。\nState 层面 For Steaming DB = Forst DB 存算分离版的 rocksdb，本地磁盘只作为 cache；\n看起来像是利用了 RocksDB 抽象出来的 FileSystem 的接口，这么说也不准确，应该得益于 RocksDB 将自己对FileSystem的访问抽象出来了。\n最后 Benchmark 结果表明：50 % 的 磁盘 cache 下，存算分离版本 state 性能和本地 state 性能差不多，略好一点点。\n打破Watermark壁垒：如何实现(跨)Flink作业的实时进度感知与自动对齐 小红书的陈宇分析了对 Lookup join 的改造，玩得比较花。\n双流 join state 大，left join 又会 join 不上，存在数据正确性的风险，延迟 join 也不好评估延迟时间。\nLeft join 比较好，但是数据可能会join不上，如果有办法保证 join 上就好办了。 核心思路是让 left join 的左表可以感知维表的进度，这样即使join不上，等一会去join就可以了。进度用 watermark 来衡量，这也意味着 左表 和 维表 需要在 event time 上是可比的，如果不可比就没有意义了。\nFlink 同步作业同步数据到维表，会在维表中记录一下 watermark 的时间；然后左表 join 维表的时候，看一下自己的 watermark 和维表的 watermark，如果 维表的 watermark 更大，就认为 join 上了，不然就认为自己的进度比维表的进度慢，就等会再 join。\n11月30日上午 上午的会场比较热闹和聚焦，美团，阿里云，腾讯，抖音都分享了湖上加速的工作，大家都不约而同地开展这方面工作，或许可以预见这未来会是个大趋势。\n美团增量湖仓Beluga的架构设计与实践 看起来像是 Hudi + HBase 的结合体，HBase 提供高效的点查 + CDC 生成能力（流能力），Hudi 来支持批上的一些能力；\n流存储Fluss：迈向湖流一体架构 本人的一个分享，主要是分享了基于流存储 Fluss 来构建湖流一体的架构；Fluss 开源的时候，很多人问 Fluss 和 Paimon 的关系，是不是竞争关系什么的，Fluss 和 Paimon 怎么选。这个talk 也算是回答了这个问题，Fluss 和 Paimon 是互为补充的一个关系。\n只需要分钟级延迟：Paimon 需要秒级延迟，不关心复杂查询能力：Fluss 需要秒级延迟，并且需要复杂查询能力：Fluss + Paimon\n理想的架构应该是 Fluss 和 Paimon 都有，但是 Fluss 把 Paimon 管起来了，用户其实只看到了 Fluss。\n这个 Talk 主要还是分析了一下目前 湖存储搞一套架构，然后流存储搞一套架构的问题，引出了 Fluss 如何统一湖流，以及在 Fluss 统一湖流架构上可以带来什么好处。\n分享完之后，大家的反响还是比较热烈，下来之后也有不少社区朋友交流了一些问题，这下没有人再有问 Fluss 和 Paimon 的区别了，问题主要集中技术细节，性能，自己的业务能不能用上，对 Iceberg 的支持等。\n欢迎找我交流！！\n腾讯大数据天穹流批一体建设之流批一体存储BSS 其实没听这个 talk，因为我讲完之后就一直被拉过去回答问题了，所以错过了这个 talk，不过我从 PPT 推测一下实现。\n痛点也还是湖格式无法实现秒级延迟，然后就提出了 BSS，提供秒级流读 + 兼容数据湖。架构大概是 Plusar + Iceberg；数据写到 Bookie 中，这是秒级延迟的部分，然后有个 Lake Sync 来将数据同步到 Iceberg 中；不得不说，和 Fluss 还是很像的。 PPT 里面还一堆复杂的架构图，看得脑壳疼，我实在 YY 不来了。\n最后未来展望还讲了和 Fluss 的融合，我其实就很好奇，这看起来差不多的东西，是要咋融合\u0026hellip;..\nBTS - 抖音集团流批一体存储服务 痛点也差不多，湖上无法做到秒级延迟；思路也差不多，在湖上架个 server 提供秒级延迟。 基于 Hudi 做的，数据首先buffer 在内存中，然后 flush 到 HDFS 上，然后也会有个单独的 service 来将数据转成 湖格式。然后讲了BTS的内部实践收益：\n降低成本 40%：主要是以前的 MQ（实时链路） + Hive（离线链路） 干掉了，换成了 BTS 单表可同时支持实时写和批读批写 11月30日下午 下午实在听不动了！\nFlink+Paimon+Hologres，面向未来的一体化实时湖仓平台架构设计 讲了一下 Paimon 作为 Hologres 的外表以及 Hologress 的 dynamic table。\n基于 Flink 和 Paimon 构建 Pulsar 的大规模消息追踪平台 反正基本上都在介绍Pulsar，然后顺便讲了一下他们内部将 Pulsar 的数据同步到 Paimon 的实践，没太多技术干货。\n基于 Paimon x Spark 构建极速湖仓分析 讲了不少 Paimon + Spark 的优化\nCache: catalog cache,plan cache deletion vector：解决merge 读的并非限制，非主键字段的过滤条件无法下推 Scan IO 优化：各种 pushdown，各种裁剪 bucket join Flink + Doris 的实时湖仓解决方案 基本上都在介绍Doris，Doris 的存算分离架构，compute layer 不存数据，只有 cache，storage layer 存数据，基于 s3/oss，然后有个专门的 meta layer 来管理元数据等，然后讲了一下 Doris 的 LakeHouse 的解决方案，Flink + Paimon + Doris；\n","permalink":"https://luoyuxia.github.io/posts/flink-forward-asia-2024-%E5%8F%82%E4%BC%9A%E6%80%BB%E7%BB%93/","summary":"Apache Flink在诞生10周年之际，回顾了其从实验项目到流计算标准的历程，并介绍了Flink 2.0的新特性如存算分离、流批一体及AI集成，同时发布了Paimon 1.0 和Fluss 0.5，旨在提升实时数据分析的效率和灵活性。","title":"Flink Forward Asia 2024 参会总结"},{"content":"关于我 Hi, 我是 Yuxia Luo，Apache Flink Committer，Apache Fluss (incubating) PPMC，目前在阿里云工作。\n平时主要关注大数据、流处理、分布式系统以及 AI 相关的技术。\n关于这个博客 这个博客主要用来记录和分享一些技术思考，内容会涵盖：\n消息队列与流存储: Kafka 内幕、存算分离架构、RedPanda / AutoMQ / Fluss 等新系统 数据湖: Iceberg、Delta、Hudi、Paimon 的内部机制与一致性模型 数据格式: Arrow、Parquet、ORC、Lance 等数据格式的深入分析 Rust: Rust 相关的文章 AI / LLM: 大语言模型原理、推理框架、AI Agent 分布式系统: 复制协议、一致性、存储引擎 论文阅读: 数据库、流处理相关的经典与前沿论文 希望能通过写作来整理思路，也希望能帮到有需要的人。\n欢迎交流！\n","permalink":"https://luoyuxia.github.io/posts/hello-world/","summary":"博客的第一篇文章，简单介绍一下自己和这个博客。","title":"你好，世界"}]