[{"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 文件的格式为：[操作时间戳（以毫秒为单位）.[操作类型].[操作状态]。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 为前缀，具体而言则是：[file_id]_[write_token]_[timestamp].[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 为文件名的一部分的，[file_id]_[write_token]_[timestamp].[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 -\u0026gt; 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 是[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":"你好，世界"}]