LruCache
。唯一的问题是,为了足够安全,API 不太易用。这篇文章完美地解决了问题。1 |
|
这里的主要问题是,LruCache
必须通过一个 closure 创建,且携带了一个 'brand
生命周期,如果想存在某个 ADT 里,会将生命周期泛型向上传染。
在我的朋友的提示下,我发现我们可以将 LruCache
的数据和对它的操作分离,为此,我设计了一套新的 scope API。
首先,在原本的代码之上,我们将 'brand
从 LruCache
上删除,然后引入一个新的结构体 CacheHandle
:
1 |
|
很显然,LruCache
可以通过常规的方式构建:
1 |
|
这里 CacheHandle
替代了我们上一篇文章中 LruCache
的职责,需要与 ValuePerm
深度绑定,为此我们引入了 LruCache::scope
API,替代了原来的 new_lru_cache
。
1 |
|
这个函数接受的是 &mut LruCache
,保证了同一时间只有一个 handle
进行操作。
这里的回调签名 for<'brand> F: FnOnce(CacheHandle<'cache, 'brand, K, V>, ValuePerm<'brand>) -> R
,对比于上一篇文章,这里不再需要把 handle
和 perm
返回,因为 CacheHandle::cache
仅仅是对 LruCache
的可变引用,即使对 handle
提前 drop
,scope 内对 value 的引用仍然是合法的。
我们将上一篇文章中的方法分别实现在 CacheHandle
上:
1 |
|
这些 API 的正确性,上一篇文章已经论述过,这里不再重复,有了这些 API 以后,我们就可以这样用我们的 LruCache。
1 |
|
由于 cache
现在是 Owned type,不借用任何生命周期,我们可以非常自由地将它存在任何地方。而只需要在修改的时候创建一个 scope 即可。
更近一步的,我们甚至可以将 LruCache
原来的方法添加回来,当我们不需要 reference stability 时,可以不需要引入额外的代码复杂度。
1 |
|
我们可以像正常的 collection API 一样来使用它:
1 |
|
我添加了一大堆 UI Test,来覆盖各种使用场景和应该编译失败的场景,感兴趣的朋友可以看一下这些 test case。
上一篇文章的末尾,我们提到了这套 API 较差的易用性可能限制了它的应用空间,那么这篇文章的改进在我看来,如果可以证明其 soundness,甚至达到了可以合并到标准库的程度(我会尝试给标准库的 LinkedList
、VecDeque
等提 Reference Stability 的 RFC)。这个将数据和操作分离的 API 改进,我们只需要在原本的数据结构上单独添加一个 scope
API,并且在对应的 closure 内遵循权限分离的设计。而在不需要 reference stability 的场景,我们不会引入任何额外的代码复杂度,真正地做到了 “pay as you needed”,这非常符合 Rust 的设计哲学。
Rust 有个 crate lru 实现了这个数据结构,这里摘取了几个关键方法:
1 |
|
相比于 lru,这里对函数签名做了一些简化,省去了与 Borrow
trait 有关的优化。
可以看到,与常规数据结构不同的是,这里的 get
方法,也需要接受 &mut self
,这给使用者带来很多困扰。例如我们可能希望同时持有多个 V
的不可变引用,这可以允许我们减少不必要的 copy,或者并发地使用他们。
1 |
|
我们会得到如下报错:
1 |
|
很显然,LruCache::get
使用了 &mut cache
导致 x
已经占有了 cache
的可变引用,而后面尝试创建 y
、z
多个 &mut cache
同时存在违背了 Rust 的 borrow checker。
那么 Borrowck 认为是错的,就是错的吗?如果我们再看一眼上图演示的 LruCache
在 get
过程中的调整,我们会发现这个限制是完全没有道理的。get
确实会调整 LruCache
的结构,但并不会影响 val
的指针,第二次 get
完全不会让第一个 get
获得的 &V
失效。
那么反正实现是 unsafe 的,我们可以将 get
改成接受 &self
吗?
1 |
|
这显然是错的,接受 &self
意味着我们可以并发调用 get
方法,链表就会被彻底破坏导致 UB,除非我们使用 Arc<Mutex<_>>
/ AtomicPtr
等同步原语保护链表节点引入不必要的 overhead,或者将 LruCache
标记为 !Sync
,这与我们原本的目的背道而驰了。
我们希望有一种接口设计,它不允许我们并发调用 get
方法,但 get
返回的 &V
不独占整个 cache,从而允许我们合法地同时持有多个 &V
.
1 |
|
这个函数目前有两个生命周期参数,一个是对 cache 的引用 'cache
(前文中的 'a
) ,另一个则是完全无关,甚至可能非常短的 'key
(在前文中我们利用 rust 的规则省略了这个参数)。这两个都不可能描述我们返回值的生命周期,我们需要一个更好的生命周期参数,基于这个思路,我们尝试引入了一个新的生命周期:'token
。
1 |
|
这样我们之前同时持有多个 &V
的代码就能编译通过了!Happy Ending?
1 |
|
我们很快发现在修改了 get
的签名后,我们在 safe rust 中,在 &V
仍然合法时调用 put
。这个方法会覆盖现有的 value,或者淘汰最旧的 entry。被覆盖的 entry 会在返回后被 drop。在此之后我们继续访问 x
就会导致 UB。办法也非常简单,我们只需要修改 put
的签名即可。
1 |
|
1 |
|
由于 put 需要 &mut Token 作为参数,而 &Token
已经被 x
/y
/z
不可变引用了,这里会编译失败。
现在我们可以给 Token
一个更好的名字了,不难发现,Token
其实是对 value 本身的读写权限。我们将它重命名为 ValuePerm
。
&self
,代表对 LruCache
的结构有读权限。&mut self
,代表对 LruCache
的结构的结构有写权限。&perm
,代表对 LruCache
的 value
有读权限。&mut perm
,代表对 LruCache
的 value
有写权限。1 |
|
我们选取的函数是非常典型的四种用例。一个意外的收获是,由于 peek_mut
不持有 &mut self
,它返回的引用可以和 len
同时调用,虽然这可能是个没什么用的 feature。
1 |
|
下一个要解决的问题是唯一性。很显然,一个 LruCache
只能被一个 Token
操纵,这里我借鉴了 Ghost Cell 和 std::thread::scope
的思路。利用 invariant lifetime 构造了一个唯一的 ID。
1 |
|
我们移除了 LruCache::new
方法,强制通过 new_lru_cache
创造一个 scope,并且在 scope 内使用。LruCache
和 ValuePerm
共享一个唯一的生命周期作为 ID。
1 |
|
实现到这里的时候,我发现我忘了一个非常致命的问题,我可以修改所有的方法来接受 &mut Perm
,但没法修改 Drop
,这可能导致 cache
早于 &V
被释放。而使用 'cache: 'perm
作为 constraint 会导致 &V
再次被 &'cache mut self
限制。
1 |
|
如果 Rust 有 linear type 支持的话,我们可以阻止 LruCache
被 drop,必须通过类似 consume(self, &mut ValuePerm)
之类的方法来销毁,很遗憾的是 linear type 属于有生之年系列,因此我这里用了另一个 workaround:
1 |
|
这里要求 new_lru_cache
接受的回调必须将 perm
和 value
的所有权返回再统一 drop。由于 'brand
的唯一性,回调必须将变量返回而无法提前销毁。
1 |
|
至此我们的接口设计就大功告成了,具体实现几乎可以完全从 lru copy,反正 unsafe 可以操纵一切,我们只要保证上层接口足够安全就行。
我实现了一个简单的完整可用版本,开源在 https://github.com/TennyZhuang/ref-stable-lru,并且已经发布到 crates.io ref-stable-lru,感兴趣的可以试玩一下,特别是提出一些 unsound 的宝贵意见。
熟悉 C++ 的朋友都知道,C++ STL 有一个非常黑暗的概念,叫 reference/iterator stability,或者叫 reference/iterator invalidation。在 cppreference 中,我们可以找到这样一张图,它描述了各个 containers 在各种操作下的行为。
这个 feature 是 C++ UB 的重灾区之一,主要原因是只在 doc 里提到,完全没法在签名上约束。而 Rust 完全阻止了相关的行为,Rust 标准库的所有 collection,只要你持有任何一个 reference,你都无法对这个结构进行任何操作,这其实浪费了 collection 的很多特性。这篇文章的设计思路是将 collection 的操作权限分散到各个 Perm 上,从而提供细粒度的读写权限控制,这个思想高度借鉴了 Ghost Cell。用类似的思路,我们也可以实现一些其他常用的数据结构,例如持有引用的同时可以 push 的 VecDeque
。
RisingWave 是近期开源的一款 Rust 写的云原生流数据库产品。今天根据下图简单介绍一下 RisingWave 中的状态管理机制:
在 RisingWave 的架构中,所有内部状态和物化视图的存储都是基于一套名为 Hummock 的存储来实现的。Hummock 并不是一个 storage system,而是一个 storage library。Hummock 目前支持兼容 S3 协议的存储服务作为其后端。
从接口上,Hummock 提供了类似 Key-Value store 的接口:
可以看到,与一般的 key-value store 接口不同,Hummock 没有提供正常的 put 接口,而是只提供了 batch 的输入接口。同时所有的操作都带了 epoch 的参数。这与 RisingWave 基于 epoch 的状态管理机制有关。
RisingWave 是一个基于固定 epoch 的 partial synchronized system。每隔一个固定的时间,中心的 meta 节点会产生一个 epoch,并会向整个 DAG 的所有 source 节点发起 InjectBarrier
请求。source 节点收到 barrier 后,将其注入到当前数据流的一个切片。
1 |
|
对于 DAG 中间的任何一个算子,如果收到了一个 barrier,需要依次做一些事情:
3 是本文想介绍的重点。简单来说,RisingWave 既不是一个 local state backend,也不是 remote state backend,而是一个混合形态。只有最新的 barrier 之后的 state 才是算子自身维护的 local state,而之前的数据则是 remote state。当且仅当收到 barrier 的时候,算子才会选择 dump 状态到 hummock store。这也就是 hummock store 只提供 ingest batch 接口的原因 ———— 算子只会在收到 barrier 的时候将 local state dump 到 hummock 中去。
前文中我们提到,算子在收到 barrier 时,会选择 dump 数据到 Hummock,但我们也提到了 barrier 是随着数据流一起流动的,如果每个算子都需要同步地将等待状态被上传到 shared storage(目前是 S3),那么数据处理就会 blocking 一整个上传的 Round trip。如果 DAG 中有 N 个有状态算子的话,那么 barrier 在整个传递过程中就会被 delay N 个 round trip,这对整个系统的处理能力会产生很大的影响。因此,我们将 barrier 的处理流程几乎全异步化了。有状态算子在收到 barrier 后需要做的唯一一件事,就是将当前 epoch 的 local state 同步地 std::mem::take
走,重置为一个空的 state,让算子可以接着处理下一个 epoch 的数据。这也引入了一系列的问题:
为了解答上面的这些问题,我们引入了 Shared Buffer。
Shared Buffer 是一个 Compute Node 的所有算子共享的一个后台任务,当有状态算子收到 barrier 之后,local state 会被 take 到 Shared Buffer 里。
Shared Buffer 主要负责以下事情:
这里的 3 和 4 很好地回答了上一小节提的问题。
MergeIterator
来做这个泛化。由于大部分状态在 remote state 中,RisingWave 可以很简单地实现 scale-out,然而带来的代价也是很明显的。相比于 Flink 这种 local state 的设计,RisingWave 需要多很多 remote lookup。
我们以 HashAgg 为例,当 HashAgg 算子收到 Barrier 后,它会把当前 barrier 的统计结果 dump 到 shared buffer,将算子本地的 state 重置为空。然而在处理下一个 epoch 数据的时候,最近处理过的 group key 很可能依然就是热点,我们不得不重新从 shared buffer 甚至 remote state 重新将对应的 key 捞回来。因此我们的选择是,在算子内部不再将之前 epoch 的 local state 重置清空,而是将其标记为 evictable,当且仅当内存不足时,再清理 evictable 的数据记录。
基于这个设计,在内存充足的情况下,或者对于状态非常小的算子(如 simple agg 仅有一条记录),它的所有状态都在内存里,且都由当前线程去操作,达到了最大化的性能,而 dump 仅用于 recovery 和 query。对于内存不足的情况下,或者对于有明显冷热特征的算子(如 TopN),那么既能保证正确运行(冷数据去 remote lookup),又能充分榨干每一分内存,
State 并不是上传到 shared storage 就不再修改了,RisingWave 会有后台的 compaction 任务。
Compaction 主要有以下目的:
执行 compaction 任务的 Compactor 可以灵活部署,既可以挂载在计算节点,也可以由独立进程启动,未来在云上也会支持 serverless 任务来启动。Compaction 任务的调度可以根据用户的需求来调节。如同 Napa 里提到的,如果用户同时需要 freshness 和 query latency,那么理应付出更多的 cost 来执行更频繁的 compaction 任务,反之的话则可以帮用户来省钱。
如果我们重新 review 一下整个 state store 的设计的话,就会发现这是一颗基于 cloud 的大 LSM 树。每个算子的 local state 和 shared buffer 对应于 memtable(允许 concurrent write,因为所有 stateful 算子保证了 distribution),而 shared storage 里存储的则是 SSTs,meta service 则是一个中心化的 manifest,作为 source of truth,并且根据元信息触发 compaction 任务。
本文简单介绍了 RisingWave State Store 的基本架构和设计上的 trade off。核心思路是尽可能利用云上 shared storage 的能力,享受 remote state 的优势 – scalability 和更强的弹性扩缩容能力,又希望在 hot state 较小的场景依然能达到 local state 的性能。当然这一切并非毫无代价,而在云原生的架构下,我们可以让这个 trade-off 由用户来选择。
RisingWave 是一个活跃开发的项目,设计也在活跃迭代中,目前我们也在上述设计之上引入了 Shared State,以减少存储的状态,之后有机会展开介绍。更多的设计文档,可以在 RisingWave 的 repo 找到。
]]>全世界有几千种编程语言,任何一个系统学过编译原理的本科生,都可以设计出自己的 toy language 并实现一个 mini compiler。大部分语言都会设计自己喜欢的语法去表达一些通用的基础设施:基础类型、字符串、变量、条件分支、循环、函数、结构体,这些都是朝三暮四、朝四暮三的区别,也不会成为一个语言本质的创新。 本文不会介绍 Zig 的基础语法,而是想安利一下 Zig 的一个重要 feature —— comptime。
C++ 有非常强大的编译期运算能力,meta programming 的魔法层出不穷,且在每个 C++ 版本越迭代越博大精深,然而对于学习者来说,是非常陡峭的学习曲线。Meta Programming 完全是内置于 C++ 编译器的另一套语法非常复杂、报错非常不友好的函数式编程语言。曾经看到过一个观点(来源请求),如果只是为了在编译期生成足够高效的代码,与其将元编程做得越来越复杂,不如直接引入 Python 作为编译期的胶水语言。那么 Zig 就做出了一个类似的选择:Zig 在编译期引入 Zig 自身作为胶水语言来生成代码,这就是 Zig comptime。
我们将以迟先生的类型体操(上篇) 中实现的一些例子来学习一下 zig。
迟先生的类型体操中实现的 Array 本质上是 Apache Arrow 内存格式 的一种实现,我们也可以尝试在 Zig 中实现一下。
FixedArray
原文中PrimitiveArray
存的是可空的定长元素组成的数组,我们不妨将它改名为 FixedArray,由一个代表是否为空的 Bitmap 和存储元素的 collection (这里是 ArrayList)组成。而 StringArray
(这里简化成 BytesArray
)是存储变长字符串的集合,由 Bitmap、偏移量数组、和拍平的字符串内容组成。
1 |
|
可以看到,在 Zig 中,””泛型类型 FixedArray
本质上就是一种接受一个类型,返回一个结构体的函数而已。在这个函数里,我们可以执行任意的表达式检查输入类型参数的合法性,甚至可以根据输入参数用 if else 返回不同的结构体。不妨假设我们添加了一个需求:对于 FixedArray,如果每个元素大于 8 个字节,也应该返回引用而非值本身。我们可以改少数代码完成这个需求:
1 |
|
@This
很像一般语言里的 receiver,但它其实只是一个的编译器内置的函数,返回了正在定义中的类型参数实例,我们用 Self 为它起了个别名。既然类型只是一种编译期变量,那么相应的 Associated type 也只是结构体上的一个编译期常量而已,我们根据编译期计算的 @sizeOf(T)
来确定 Ref
的类型。这里 @sizeOf 是一个例子,如果想要,我们也可以用斐波那契数来确定 Ref 的类型 :(
原文中实现了 StringArray
作为变长数组的例子,这里我们简化为 BytesArray
。
1 |
|
这里更直接了,BytesArray
就是个类型为 type
的常量。我们也定义了 Ref
常量来模拟 BytesArray
的『关联类型』。通过将 Ref
定义为切片([]u8
),我们轻松实现了返回引用而非拷贝数据。
我们需要一个 Builder 来构造不可变的 array,构造的过程跟读取是类似的。
1 |
|
这里有一些小问题,比如标准库的 DynamicBitSet
并不能高效地 append 一个 bool,不过可以暂时忽略。我们将 ArrayBuilder
和 Array
通过 ArrayBuilder::Array
关联起来,当然我们也可以顺着 Array 再找到 Ref,如 fn append(self: *Self, v: Self.Array.Ref)
。我们也可以在 Array 的结构体中添加对应的 Builder
1 |
|
这个对应于 用 Rust 做类型体操 (下篇),得益于 comptime 的简单设计,我们直接逃课了类型体操的大部分。
在原文中,作者用了非常复杂的 macro,用类似 callback 的编程范式来实现了逻辑类型和物理类型的关联,而在类型即编译期变量的 Zig 里,这一切都非常自然。
1 |
|
同样,基于 comptime,我们也可以用非常流畅的逻辑将表达式的类型和 DataType 的数组直接映射到表达式的实现,不过这篇 blog 已经太长了,暂时不过多展开了。
事实上,Zig 已经在标准库里内置了类似 Multi-dimentional FixedArray 的东西,且非常灵活。
1 |
|
可以看到,使用体验都跟 ArrayList
几乎完全一模一样,但是内部确实按字段列存储的,它的内部实现大量使用了编译期反射生成友好的代码。这对 Data-oriented Programming 的场景是非常友好的。
feature(const_eval)
至今都不支持 heap allocation,只要一个函数用到了 Vec
, Box
, String
等任何堆上分配的资源,都不能被标记为 const fn
。对比 C++ template:
对比 External generator( go generate
或 build.rs
) 等方案:
对比 rust proc-macro:
@TypeOf
, @field
等都可以拿到非常开箱即用的信息。对比 Generics:
如果要用一个合适的词形容 zig comptime 的话,我觉得『大道至简』是一个非常好的描述。这不仅仅是在玩梗,而是一种真实的感受。
作为大道至简的代表,Golang 在 1.18 之前一直不支持泛型广为人诟病,但 Golang 设计之初就是不希望引入过高的复杂度和学习负担,我其实可以理解这个选择(这不妨碍我不想写 Golang)。在最新的版本里,Golang 引入了一个非常残废的 Generics,支持的功能非常有限。而如果想支持更多的抽象需求,不可避免的要引入一些相对复杂的设计如 covariance,partial specification,甚至 higher kinded type,这也背离了 Golang 的设计初衷。也许对于 Golang 来说,在 1.17 的 IF 线里,选择从 go generate
进化到 comptime 是更好的选择 —— 用更低的复杂度和学习成本换来了非常强大的抽象能力。
Zig 依然是个比较早期的语言,没有发布 1.0 版本。相比于 Zig,我也更喜欢使用 Rust,但 Zig 依然有一些非常惊艳的 feature,comptime 只是其中之一。同时 Zig 也有非常好的交叉编译基础设施,我很期待 Zig 能成为未来系统编程语言中 C 的一个重要替代品。
]]>1 |
|
在这个 Rust Playground 里可以看到结果。
Rust enum 本质是一种 tagged union,对应代数数据类型中的 sum type,这里不过多展开。在 Rust enum 的实现中,通常用一个 byte 来存储 type tag(大部分 enum 不会超过 256 种类型,更多地会相应扩展),也就是说,理想情况下,以下两个结构体是等价的:
1 |
|
1 |
|
在这个实现下,enum 在很多场景下并不是 zero overhead 的。幸运的是,Rust 从未定义过 ABI,而带有数据的 enum 甚至是无法被 repr(C)
表示的,这给了 Rust 充分的空间对 enum memory layout 进行细粒度的优化。这篇文章会涉及一些在 rustc 1.60.0-nightly
下相关优化的介绍。
在开始具体的探索之前,我们需要准备一个辅助函数,来帮我我们查看变量的内存结构:
1 |
|
以上面的 Attr
为例:
1 |
|
Option<P<T>>
P 是常见的智能指针类型,包括 &
/&mut
/Box
。这应该是关于 enum layout 优化里最著名的一个例子了。Rust 推荐使用 Option<P<T>>
来处理可空指针,这实现了 null safety.
Option<T>
在 rust 中被表示为一种 enum:
1 |
|
如果不作任何优化的话,显然是存在不必要的 overhead 的,空指针可以完整地表示 None
的语义。由于这种情况太过常见,rustc 不仅针对性地做了优化,而且将其标准化了。
If T is an FFI-safe non-nullable pointer type, Option
is guaranteed to have the same layout and ABI as T and is therefore also FFI-safe. As of this writing, this covers &, &mut, and function pointers, all of which can never be null.
1 |
|
一个不算太冷的冷知识是,这种 hack 并不是针对 Option
的,而是针对指针类型的。任何自定义的 enum 满足条件也可以达到相同的效果。
1 |
|
Option<P<T>>
可以优化的根本原因是,P 的内存表示下有一个永远非法的值,而相应的 enum 仅需要表示一个额外的值来表达多余的类型。超出这个约束就会导致这个优化失效。
1 |
|
bool
, Ordering
rust 中的 bool
占用一个 byte,且仅有两个合法的值,True
和 False
,对应的内存表示为 1u08
和 0u08
。我们可以理解为 bool
有 254 个非法值可以供 type tag 挥霍。
1 |
|
更进一步地,我们可以更给力一点:
1 |
|
对应的,Ordering 有三种合法值,同样适用于这个优化。
1 |
|
事实上,编译器并没有对 bool、Ordering 进行特判,任何种类少于 256 的 enum 本身都满足被优化的条件,即 type tag 里会有 (256 - kinds) 个空位。
1 |
|
Struct、Tuple 等都属于 Product type。在实现中,往往是将所有字段依次存下来,并做额外的 padding。那么一个理所当然的优化是,如果 struct 的其中一个字段有空位,那么就可以将 enum tag 塞进去。
1 |
|
我们再回到文章开头的例子。
1 |
|
A 和 B 由于 padding,都需要占用 16 个 byte,而 Option<B>
由于存在一个 bool 字段 .2
,tag 被优化进 bool 了,因此也只需要 16 个 byte。反而 Option<A>
实打实地用了 24 个 byte。
一个很容易想到的优化是,使用 padding 中未定义的内存来存储 type tag。比较遗憾的是,A 的 layout 是在编译 A 自身时确定的,而 Option<A>
在 A
的 padding 中存储的数据是未定义行为。这也导致了一个比较滑稽的结果,多存了一个字段,Option
占用的空间反而减少了。
当然,使用 padding 存储数据是完全可能的,但前提是不能影响子数据结构的 memory layout。如果我们将 A
在 Option
中手动展开:
1 |
|
这种情况下,由于 OptionA 的数据直接保存在 Some 内,实例化的时候完全可以使用 padding 存储 type tag,而不会引起潜在的未定义行为。
目前,所有 enum layout 相关的优化都适合由编译器针对特定的类型进行 hack 来实现的,我们无法自己控制我们自定义的 struct 在 enum 中的 layout。为了优化一些常用场景,rust 又提供了 NonZero*
等辅助结构体,用来表示非 0 的整数,与此同时编译器会让 size_of::<Option<NonZeroU8>> == size_of::<u8>
。但这只能由标准库 case by case 处理,而真实的需求是非常复杂的,比如有时候我们可能需要 NonMaxU64
,或者例如使用了第三方库的 Option<ordered_float::NotNan<f64>>
就无法被优化。
针对自定义接口的优化需要引入非常复杂的机制,在编译期告诉编译器一个类型非法的内存结构有哪些。我目前感觉一个可能的实现是 const trait + const iterator,给编译器提供潜在的非法值的迭代器。不过目前没有看到相关的 RFC。
这篇文章所有的 example 可以在 Rust Playground 找到。
]]>即将迎来 25 岁生日,随便记录一些想法
时间飞逝,回忆起决定加入阿里的日子,仿佛没有过去多久一样。
因为真的没有过去多久
我在阿里的这段经历大约一年半,从个人体验来说,甚至可以用远超预期来形容(毕竟外界对阿里的风评太差了导致我的预期非常低),我发现阿里也有很多不 PUA,踏踏实实做事的 leader 和氛围健康的组,仅仅因为阿里就贴死标签是不可取的,想来大厂做数据库的同学欢迎找我内推,极力推荐。
在阿里工作的这段时间,为我们组的产品做了一些还算有意思的 feature,有兴趣的可以看我们组的专栏中跟事务有关的文章。
之所以选择那么快离开,更多的是对自己的一些成长焦虑:比如感觉做的东西都是非常 trivial 的东西,明明花了不到一个月就完全搞懂了,却需要花费十倍以上的时间在跟一些历史包袱作斗争上;做事情很难,推事情很难;还有很多很多
我一度把这种成长焦虑误以为这是职级焦虑,毕竟某个scope内最年轻的P7听起来是个很好听的title,也带来了巨大的对保持快速晋升的渴望,看不到合适的机会是令人焦虑的。但我很快就发现这只是表象,在我观察了许多P8,发现他们能做的事情甚至可能还不如我之前作为一个 new grad 在旷视能做的事情多后,我明白这与职级无关,是一种对成长停滞的焦虑,这样的P8也并非是我现在想追求的东西(当然他们的package还是很动人的)。
在内部听过一个高P的经验分享,谈他们的成长经历,我发现他描述的几年前野蛮生长中的阿里云更符合我的期待,而这样的机会随着阿里云各个生态位的补足,已经非常稀缺了。现在的阿里云有很强的数据库团队,但没有我的机会。
熟悉的朋友应该都已经猜到了,在经过多方比较以后,我最终选择了加入一家姚班学长创立的明星 startup,希望在一个新赛道能做出一些事情。虽然跟之前的方向不太 match,但我很看好这个新方向,也十分相信自己的能力。预期下周就会加入~
在跟 leader one one 的时候,我能感受到他的遗憾,其实我也十分遗憾,我们聊了很多事情,从团队,到阿里,到行业。在最后的最后,leader 叹了一口气,我跟他说,比起遗憾,你更应该祝我早日财富自由 ( ´ ▽ ` )ノ
在 leader 的祝福中,我结束了这段旅程。对于阿里这家公司,我没有什么好感(当然相比加入之前好很多),我更相信“一鲸落,万物生”。但对于团队的很多人,我还是非常感激与祝福。
在阿里的最后一个月,除了交接工作以外,我把之前积压下来想读的七八篇 paper 都看了一遍,写了好几篇文章,又看了不少 compiler 有关的东西,感觉停滞了一段时间的姿势水平又开始 +1s +1s 地流动了。
每次生日都是非常开心的事情,但既满怀忐忑又充满希望的上一次生日应该是发生在八年前。
八年前的纯弟弟在一所野鸡区重点放弃学业一个人花了一年自学竞赛,这是他人生中第一次证明自己的考试前的一个生日。这次生日过后纯弟弟成了全校前后几十年唯一一个清北(按照当年人人网友的说法,二本变清华)。恭喜纯弟弟 🎉
那么这次纯哥哥也请相信自己的选择吧!
]]>从最简单的概念来说,Noria 就是一个异步的增量物化视图,每条更新被异步地推送到用户创建的每个 External View 上用于查询,这些是计算图的叶子节点。
这跟 Streaming system 也比较像,唯一的区别是它本身就是个数据库,因此它持久化了 Base Table。主体不记录了,主要讲讲它的一些特殊设计。
Window Join 是 Flink 的算子之一,Window Join 的两个上游算子的输入流仅在一个时间窗口内的才会被合并,而一些变体如 Interval Join,只不过是把这个时间窗口从一个矩形变成了三角形或者梯形。这种流式 Join 跟传统数据库的 Join 有很大区别,仅能处理一部分场景。很多 Join 往往并不是在一个窗口发生的,比如老用户和新订单之间的 Join 就无法通过这种 Window Join 完成,而需要借助一些其他的系统,比如维度表。当然 Flink 只是个流式计算引擎,不可能在 join 算子维护太多的状态,只支持一个窗口是正常的。Noria 的定位则是一个流式数据库,它希望解决所有的 Join 需求。Noria 会在 Base Table 上保存所有数据,而 Join 算子上仅保存部分状态。在 Join 无法匹配成功时,通过递归的 upquery 向上查找,直到落到 Base Table 为止。(这其实很类似 TP 里增量物化视图的做法了)
Partial State 和 Upquery 是贯穿 Noria 整个系统的,而不仅仅是为了解决 join 的问题。当我们在 Noria 里新建一个物化视图时,这个视图以及所有新建的算子都是 Empty State 的。这也就给 Noria 带来了一个好处,算子变更非常地简单且快速。在有对这个视图的查询到来时,会通过 Upquery 向上 Upquery 查询数据(直到 Base Table),并一路向下填充每个算子的状态。而当整个系统状态过多时,又会通过一些策略 Evict 掉一些算子的部分状态,在需要的时候重新 upquery 计算。这样整个系统的空间占用就是 bounded 的。
Noria 提供了 eventually consistency 的语义保障。对 Noria 的每个算子来说,会有两个操作:来自上游的 Update 和来自下游的 Upquery,Upquery 本身不会修改这个算子的状态,但 Upquery 的结果是会用于计算更下游算子的更新数据的(类似读后写),因此也会影响最终一致性。Noria 的做法是在每个算子上提供 Update/Upquery 的 Ordering,类似于 Lock Based 的思路,而不是引入 MVCC。脑补了一下,如果是基于 MVCC 的实现,Upquery 会查到一个 snapshot 的数据来更新下游,那么就会稳定产生 write skew,而达不成 eventually consistent。
我觉得 Noria 最大的亮点是这个设计把 Streaming 和 Query 做到了同一个引擎里,区别仅仅是在计算图上流向的区别 —— Query 是从叶结点(External View)到根节点(Base Table)向前地 Pull 数据并选择性地缓存,而 Streaming 则是从根节点向叶结点 Push 数据的更新。从另一个角度看,Noria 的每个 External View 都是 Logical View 和 Materialized View 按照一定比例混合的结果,而 Eviction 策略调整两者的比例,我们考虑两种情况:
实际的状态则是多个维度 trade off 过的 Partial State:
这种 Partial State 符合大量应用的特征,很多分析往往是更关注头部用户的分析结果,头部用户无论是产出内容还是粉丝数交互数都更多,相同的查询重新计算的代价也高。而应用的不活跃用户往往查询频率很低,且重新从 Base Table 计算代价也很低,用跟头部用户相同的状态(空间、写放大)为他们维护所有 View 是很不划算的事情。Noria 可以全自动地做这个事情。
从这个结果来看,Noria 在开头对自己的定位就非常精准了,它是一个 eventually consistency、near realtime、以及非常易于使用的 Redis 替代品,用户可以像直连数据库一样达到缓存的效果,同时还有更好的缓存语义。
这套设计可以极大改进现在应用开发者对数据库地使用方式,举个实践上更强的例子,现在网站对内容点赞、评论等的计数往往是需要业务手动维护:
1 |
|
除了简单的业务逻辑,我们还需要维护各个缓存的状态,like_count 只是最简单的状态之一。基于 Noria,我们可以用物化视图的方式去做这个:
1 |
|
不同于经典物化视图的是,Noria 里的 tweet_likes 表可以仅维护远远少于 tweets 表量级的状态。像 496733277274013696 这种热门、常看常新的 tweet 状态肯定会被长期缓存在 tweet_likes 表里,而大量冷门的、过期的甚至从来没有人看过的 tweet 则只会存在于 Base Table(likes)中,即使偶尔需要,查询代价也非常低,当一个冷门 tweet 114514114514 因为某种未知原因被查询,tweet_likes 表里早就淘汰了对应的数据时,Noria 会自动 Upquery,等效于对 Base Table 执行了一个查询:
1 |
|
这个案例听起来更像 ”HSTP“(自造词) 而不是 HSAP?总之,大量复杂的逻辑都被 Noria 接管了,因此业务代码只需要维护最简单的逻辑就够了,通过一套系统处理这些还是很 fancy 的。
上面是从数据库物化视图的角度看待这个系统,接下来从 streaming system 的角度来看它解决的问题(主要是有 Base Table 就能做很多事情):
吹完喜欢的点,聊聊局限性:
Noria 这篇 paper 做了一个开源实现,是一个 45k 行代码的 toy,而且我并没有 run 起来,疑似已经停止维护了。相对于真正可以使用的数据库产品,还有很多复杂的情况需要处理,不过这个设计思想还是给了很大的想象空间。
]]>首先 brief 一下 Napa 这篇 paper 有比较有意思的点:
Napa 是 Google 用来替代 Mesa 的新一代数仓,向 Google 的广告客户以及内部用于分析。一个最重要的变更是引入了 Materialized view 的概念来加速查询。有几个重要的指标是 Napa 关心的:
当然除此之外,Durability 和 Fault tolerance 肯定是必要的。
Ingestion throughput 是不可放弃的选择(数据都导不进去谈什么查询),剩下的都可以交给客户端来 trade off。
Napa 中直接导入数据的表称为 base table,而每个 base table 会有若干关联的 materialized view(n to m 的关系,每个 materialized view 也可能不止关联一个 table)。每个 Materialized view 的结构类似一颗 LSM Tree,一条外部数据从其他系统导入到 Napa 需要经过一系列过程:
而对一个 view 进行一次 query 的过程跟 LSM 也完全相同,即并行地在若干个 delta 进行查询,并将结果合并。
而几个关键的指标基本都受到这套体系的影响:
QT 是一个表示 freshness 的概念,Now() - QT 代表了 frsehness 的 bound。QT 有一个上限,就是不能超过 Non-queryable delta 里的下界(因为 Non-queryable delta 完全不可查询),QT 是受到用户配置影响的。查询时会合并到 QT 为止所有的 delta(而不是全部的 delta)。
要 data freshness,但 query 可以慢点儿
需要 query performance,但是读到的数据可以旧点儿
既要 query performance,又要 data freshness。
没有什么是充钱解决不了的。
Google 的 infra 是真的强,所以我感觉其实 Napa 做的最重要的事情就是上面这个 LSMT 了,其他的都通过外部系统解决。Napa 使用了 Colossus(下一代 GFS)做文件存储,并且用 F1 query 做了物化视图的 Planner 和 Optimizer(我觉得是工程量最大的一部分),以及面向客户端的 query servering。Napa 自身更多负责视图的维护。
物化视图的自动淘汰
文中提了很多 challenges,但其中我感觉比较关键的一点,物化视图的及时淘汰。对用户来说,QT 是个 database 级别的概念,如果有一个物化视图的更新比较慢(也许是视图太复杂,或者 plan 优化不够),那么 Now() - QT 就会越来越大,freshness 无法保证,需要及时淘汰掉这些 view(读到这里的时候我才发现原来 view 可能不是用户指定创建的,居然还是可以自动加减的。。就离谱)。
为什么 QT 不是 Watermark?
回收一个开头的疑问,因为 Napa 并不是一个 streaming system,它的输入是 Ordering 的(或者说完全基于 Process time 而非 Event time)。Watermark 描述的是 query 的 completeness(即 query time < watermark 代表一定能读到完整的结果),而 QT 描述的是 freshness(即 query time < QT 可以获得符合预期的性能)。
Napa 提供了怎么样的一致性?
从我个人的 taste 来看,一个让人用得舒服的数据库,无论是 TP 还是 AP,提供 atomic batch update 和 global snapshot read (即使是 stale snapshot)是必要的选择。这篇 paper 关于一致性的描述非常少,不过两点观察:
总体来说感觉还是比较中规中矩的一些 idea,不过也可以看出 Google 对工程细节的把控非常深了。比如同样是 LSMT 的架构,我怀疑换成 TiDB 的话,robust query performance 可能更取决于查询线程池的调度、gRPC 各种不确定因素而非 delta file 的数量。只有在工程细节上优化得足够好,才能在各个指标上更加可控的 trade off configuration。
数据库服务需要很强的确定性,相比于 auto driven 来说,这种 trade off configuration 说不定对用户是个更好的选择。(然后我准备去看另一篇 PVLDB auto driven 了)
彩蛋:blog.zhuangty.com 终于用上 Google analytics 了,说不定 tygg 在看报表的时候也用上 napa 了
分页功能是网站常见的需求之一,对应到数据库中的实现,通常会用 LIMIT ? OFFSET ?
的子句来实现,然而这是很多网站被攻击的潜在原因之一。在主流的数据库实现中,这种查询的效率往往非常的低下。本文脑洞了一种高效支持 OFFSET LIMIT 的方法。
首先让我们看看到底有多慢。以 MySQL 为例:
首先我们在 mysql 中创建一张简单的表,并用 sysbench 导入 10M 条数据:
1 |
|
这并不是很大的一个数据量,然后我们执行一条简单查询:
1 |
|
这样一个简单的查询需要花费 8 秒左右的时间,而且当我用 sysbench 去压这条 SQL 的时候它直接挂了。在具体的实现,OFFSET LIMIT 这种操作基本都是通过扫描来实现的,很难跳过前序的行数,而 OFFSET 越大意味着需要扫描的数据越多。一个正常的用户通常只会刷前几页的数据,但是被 hack 的时候就很难说了,也许只需要把 url 里的 pageNo=1 改成 pageNo=10000 并且高并发请求一下,一些网站就挂了,当然现在这种场景已经很少了。
很难跳过不代表无法跳过,我们从 MySQL 底层的索引数据结构 B+ 树说起。
上图是一个简单的两层4阶 B+ 树,由一个非叶节点和四个叶节点组成,叶节点之间通过链表连接,用于提高 scan 性能。
我们想做一个分页查询
1 |
|
对应到存储结构上,就是希望找到这个索引树上第四个到第五个元素 btree.range(4, 6)
,20 和 24。目前的实现里,我们显然只能左到右扫描,在无意义地访问了连个不需要的节点后,我们才能找到正确的节点和对应的数据。很显然,如果我们在每个非叶节点上实时维护所有子树的 count 的话,我们就能更快地找到结果。
通过非叶节点上的 count 记录,我们就可以先通过 root 节点的 count,确认第四条纪录从第三个叶节点开始,然后直接找到数据并开始扫描,总体时间是 $log(n)$ 的复杂度。当然,这会导致我们每次插入或者删除数据的时候也需要更新一整条树路径上的所有非叶节点。
MVCC
在支持事务的数据库里,往往使用 MVCC 来减少读写之间的锁冲突。MVCC 在叶节点中的数据,存的是一串链表(O2N),代表了数据的更新记录,而 seq 代表了。支持了 MVCC 后,我们很快发现我们的 count-B+ tree 不好使了。
我们分别进行了一次插入,一次修改和一次删除,这也就导致了我们查询不同 version 的时候,正确的结果是不一样的。
1 |
|
1 |
|
我们的计数只能支持对最新数据的查询,而在修改发生之前已经创建好的 ReadView,我们就无能为力了。
多版本计数
既然数据可以做多版本,那么我们的计数理所当然也可以。
在多版本计数上,我们“如愿以偿”地查到了我们想要的结果。
B+ 树的分裂
1 |
|
上面的思路依然是死路一条,我们考虑 B+ 树的分裂和合并,多版本计数是完全不可维护的。
可持久化数据结构
我们先直接看一下我们用可持久化 B+ 树优化后的结果,为了简化做图,我们假设我们仅做了两次插入:
1 |
|
可以看到,相比于原来的 B+ 树,我们引入了两个变化:
在第一次进行插入后,我们直接 copy 了一份根节点和第二个叶节点,并插入数据,新的节点(黄色)另外三个指针依然指向原来的叶节点,只有第二个指针指向了新的节点。
而第二次插入直接引发了分裂,因此我们一共创建了五个新节点, 包括:
可以看到一波操作之后,我们拥有了三个版本的 B+ 树!每个版本都是一个符合我们预期的全局快照,我们查找的时候可以先快速找到需要的版本,然后在每个版本上快速地进行符合我们优化预期分页查询。与此同时,我们也并没有占用三倍的空间,理论的空间复杂度是 $n+m log(n)$ 其中 n 是树的大小,m 是维护的版本数。
可持久化数据结构 是一个数据结构上的概念,但跟 MVCC 不谋而合。而当我们尝试在数据库上使用了可持久化B+树后,我们事实上是把 MVCC 做到了整个索引的数据结构里,而非行记录里。整个索引结构从 BPTree<Key, PersistentList<Value>>
转变为了 List<PersistentBPTree<Key, Value>>
。
另一个问题是,为什么我把叶节点之间的连接指针删掉了,因为不删没法做,原因可以留给读者思考一下,这也是可持久化数据结构的局限性之一。
Mixing mode
考虑到这种实现的 clone 代价还是太高,也许可以混合三种方式做这个优化:
查询时:
(实现上想想就很恶心,所以连图都不想画了)
后记
后来还写了个 Rust 的实现,但写得不太好不想开源了,反正可持久化数据结构无脑 Arc 就对了
分页其实是个很小众的需求,在数据库支持不佳的情况下,业务上也有了很多替换解决方案,完全没有必要花很大代价去做优化,所以这篇文章还是搞笑为主的(不搞笑我肯定发 paper 了谁写 blog 啊),但仔细想想发现这个 idea 居然还挺 novel 的。。。
当然代价也是很明显的,写放大和空间放大前所未有的大,所以还是没有意义 :)
好玩为主,很久没有 follow 过存储引擎的东西了,idea 如有雷同都是巧合(
]]>Builder Pattern
Builder Pattern 是 rust 在复杂对象构造上推荐的一种设计模式,一个常见的 Builder 实现:
1 |
|
必选参数
这时候我们接到了一些奇怪的需求,要把 a 和 b 作为必选参数(那为什么不把 a 和 b 传进 ABuilder::new 的参数呢,小编也很好奇,但是这么写就水不了文章了)。
我们可以为 ABuilder 引入三个 bit 的常量状态,标识每个参数是否被设置:
1 |
|
迫于无奈,我们开了两个 incomplete feature 来做这个需求,一路顶着 warnings 编译成功了。
在上面的实现里,我们通过一个 Assert
的 trick,允许我们在 impl 的 block 上为常量参数 S 添加条件判断,而 S 本质上就是一个 bitflags,标识了某一个参数是否被设置过,为此我们仅为 ABuilder<S> where Assert::<{S & 0b110 == 0b110}>: IsTrue
实现 finish 方法,这就满足了我们的需求。
报错大概长这样
1 |
|
只能传递一次的参数
此时我们又对 c 提了一些奇怪需求,我们希望 c 是可选参数,但是最多只会被传递一次(即 0 或 1 次):
举一反三,这个需求太好改了。
1 |
|
总结
这确实是一篇搞笑文章,所有需求都是我在学习 const generics 先进语法的时候随便做的实验,事实上 Builder Pattern 并不适用于这些奇怪的需求,而且用到了 const_evaluatable_checked
这种纸糊的 feature 也不可能用于生产。有一个真正用于生产的 crate typed-builder 思路跟我类似,不过直接生成了一个长度为 n (n 为 fields 的数量)的 tuple 来记录状态,更合理一些。不过基于 const_evaluatable_checked
可以实现很多奇奇怪怪的需求,比如可以做编译期状态压缩 DP(rustc 爆炸中),还可以做一些奇奇怪怪的限制(比如限制一个方法最少被调用 n 次,最多被调用 m 次,完全想不到什么场景需要),可以认为是对 Rust typesafe state machine 能力的一个强化了,对于一些比较相似的状态转换过程,我们可以直接基于 const generics 来减少重复代码(DRY)。
上午摸鱼的时候,读了雷宇哥哥的文章 I beat TiDB with 20 LOC,这篇文章非常有意思,推荐 go 吹/go 黑都可以读一读,通过这篇文章,我发现了 cgo 比想象的要快很多,以及 go 编译器比想象更烂,以至于 cgo 的 overhead 完全抵消了还不够。在学习 TiDB 先进经验的时候,看到了一段有意思的代码:
1 |
|
要理解这段代码,首先我们需要理解什么是 chunk。chunk 是一种列式内存格式,是 Apache Arrow 的一种实现,具体可以参考 TiDB 源码阅读系列文章(十)Chunk 和执行框架简介。对于这里的 Int 类型,Column 的内部其实是一个 int64 array 和一个 bitmap。
这段代码的目的就是将 lhi64s 和 rhi64s 加和的结果保存到 resulti64s 中。为了彻底理解这段代码,我们还需要关注一下调用它的函数 builtinArithmeticPlusIntSig.vecEvalInt
:
1 |
|
这段代码主要是根据加法算子两侧表达式的类型,决定调用具体的实现,而 plusSS 则是其中的一种(两个有符号整数相加)。但在调用具体的实现之前,已经对 lh 和 rh 分别调用了 result.MergeNulls()
,因此在 plusSS
中只需要对 result 是否为空进行判断,决定是否跳过计算。
1 |
|
1 |
|
理解剩下的代码就非常直观了,做一个 overflow 判断,然后返回相加的结果。这也是针对不同符号的 Plus 函数最大的区别。
在 MySQL 大部分 expression 的逻辑,对于输入参数包含 NULL 的情况,输出参数往往也是 NULL,TiKV 为了 DRY,使用宏简化了这个逻辑。
1 |
|
这段代码进行宏展开后和上面的 go 代码类似,如果参数有 null 的情况会直接跳过真实 like 的调用。但对于 Plus 这种基础的数学运算函数,事情就有了一些变化。一个常识是,分支的代价其实比 add 这种基础指令大很多,在高效的列式执行逻辑里,这里引入了两个 branch 操作,相比于运算(一次 add)本身来说,这两个 branch 才是最大的 overhead。
且这两个 branch 是必要的 check,同时也是 unlikely 的(大概率会走 else 分支)。在完全不改变代码逻辑的情况下,我们可以将它们优化成一个 branch。
1 |
|
是的,代码改动只有三行,且只是原封不动 Copy & Paste(点题),我们来看看 benchmark 的结果。TiDB 非常贴心地提供了表达式模块的 benchmark 脚本,我们可以直接使用:
可以看到,四种函数的提升都是非常可观的。其中第一个 Case 更是直接性能翻倍。
这个优化的原理是非常简单的,Plus
这种基础函数满足两个特性:
那么我们可以直接删除 null check 的三行代码,因为 null 的结果已经被写到 result 的 bitmap 里了,这里无脑做一下计算就可以。但由于 overflow check 的存在,我们不能返回非预期的 overflow 错误(可以说 plus 运算本身无状态,overflow check 引入了状态),因此如果发生了 overflow 的话,我们得确认本身不是 null,如果确认不是 null 的话,才返回错误。从某种意义上,这个优化可以认为是手动帮 CPU 做流水线执行。
上面的 PR 是我花十分钟写的,我是想再给力一点的,但是懒(躺),下面只说失败的尝试和思路。
首先是想办法优化掉 overflow check 的 branch,我先把 overflow check 的 branch 提取到外侧:
1 |
|
这个优化对逻辑是有变更的,因为报错信息里没法输出正确的数字了,但也不是完全没有办法,考虑到 overflow 是小概率事件,我们可以发现 overflow 以后再循环一次,拿到具体 overflow 的数字。
b2i
和 b2iNot
大概就是 bool2Int,具体懒得贴了,反正大道至简不需要解释。实测效果大概 benchmark 低了一倍,我又懒得看汇编调优(下次高兴了再水一篇 blog)。猜测有几个原因:
但总之想优化掉 overflow check 是一件 non-trivial 的事情,
既然解决不了问题,我们不妨解决提问题的人。对于 AP 性能有极致追求的用户,我们可以提供某种 non-strict sql_mode,允许整型 overflow,结果是未定义的,这样我们就能自由地去掉 check 了。
1 |
|
能啊,直接看雷宇哥哥文章的 SIMD 版本 I beat TiDB with 20 LOC。
只能说,TiDB 的 Executor 还有很长的路要走(
你是不是标题党?这哪里提升两倍了?
是,expression 的 benchmark 翻倍,但在整个 SQL 的生命流程中只是微不足道的一部分,特别是 TP 的请求。即使对于复杂的 AP 请求,除非是完全从本地节点的 cache memory engine 中读取的数据,否则相同的数据量网络开销大概率也会超过这部分的优化效果(特别是 TiDB 和 TIKV 交互还用的 gRPC,那就更拉了)。
说好的福利(伪):我改完 Plus 就懒得改了,目测 Sub/And/Or/Not/Xor/… 肯定都能提升的,Mul/Div/Mod 可能需要 benchmark 一下才知道有没有提升,同时 TiKV 上也可以水一堆,感兴趣的可以去水一个 PR 薅 TiDB 的 new contributor 周边杯子。有能力的也可以把 non-strict Plus 实现一下(也很 trivial,就是要思考一下用户接口)。
]]>🐶狗头保命
更新 blog 主题是一个比写 blog 文章快乐多了的事情,这也是 blog 新手常常陷入的一个陷阱 —— 精心配置一整天的主题、评论、评论、插件,然后写下一篇 类似于 Hello World 的《使用XXX 搭建 blog》之后从此吃灰。为了避免自己陷入这个陷阱,我搭 blog 的时候给自己定下了一个规则 —— 每次写一篇文章才能更新一次与文章无关的 blog 配置。
最终的结果是 —— 我既没有保持合适的更新频率,也没机会折腾主题,直接使用了烂大街的 hexo next,没有评论,没有 Analytics,甚至连 Hello World 都没写,创造了一个三无 blog。
直到今年开始,我成功更新了两篇文章(这里非常感谢 Taio App,让我在手机上也能快乐地写 blog),适当地让 blog 更易用一些也提上了日程
第一件事情自然是喜闻乐见的换皮,选了
hexo-fluid-theme,就觉得挺好看的,配置也比较完善,代码也不复杂,看了看感觉如果有必要的话(其实很快就有必要了),我自己也改得动。
配置的过程中,遇到了两个麻烦,一个是这个主题必须配置一个 banner_img,而且默认的太丑了,于是为了提高辨识度,我随手画了一个更丑的,以后有机会再优化(下次一定)。另一个是主页上要求写一个 slogan,我想把 迟语录 chi_corpus 随机显示在主页上,但是 hexo-fluid-theme 只支持 json 格式,即使我 commit 一个 json 格式上去,也没法做到随机返回,独立维护一个转换服务又会极大增加我的运维负担,因此动了薅 vercel 羊毛的心思。翻了翻 vercel serverless 的使用文档,感觉使用起来非常简单,而事实上也是如此。仅仅是花了五分钟,添加一个四十行的 go 文件,我就轻松达到了我的目的。可以在首页观看效果。赞美 vercel(*1)!
1 |
|
本来鸽子博主已经决定更新到这里结束了,看了推友 @frostming90 的 blog(别人的 blog 真好看啊),于是进行了一番抄作业。再吹一次,vercel 真的好用(*2),无脑就把一个 blog 后台跑起来了,而且还是白嫖。这次遇到一个新问题,就是 hexo-fluid-theme 支持了许多 comment plugin,但还不支持 cusdis。顺手 fork 了一个支持了一下,顺便提了个 PR https://github.com/fluid-dev/hexo-theme-fluid/pull/474。目前 console 里还有个奇妙的报错 Function called outside component initialization
,但似乎不影响 plugin 的使用,有知道怎么修的也可以带带我,前端技能不太熟练了:(
目前对 cusdis 还有一些小小的问题,比如评论仅支持审核后显示,似乎不支持默认显示的方式,使用起来比较麻烦,后续考虑 contribute 一下。欢迎试用新整的评论系统~
个人搭建并维护 blog 还是比较麻烦的事情,一个关键技巧是在掌控数据的前提下尽可能依赖第三方服务。这次的整套组合拳打下来基本也就花了半天的时间在折腾(没有 vercel 可能一天起步了,点赞*3),但数据是以可以掌控的格式(postgresql,可以自己备份)存储的。考虑到 Donald Trump 被封禁到搭建了自己的个人博客,可见 blog 这种去中心化的组织形式还是有必要的。
]]>尽管是完全与安全方向无关的主题,但我们随手取了个队名 ‘ or 0=0 or ‘(读作「引号or零等于零or引号」),除了可以卖萌,而且还能用于 SQL 注入,感兴趣的可以看看万能密码这个 topic。很意外的是还引发了 hackathon 过程中一个有趣的小插曲,在看到我们的队名后东旭和姚老板纷纷吐槽太有年代感了:
但仅仅过了半天,在我们 hack 的途中,我们的队友 breeswish 就无意中发现 TiDB 的内部 SQL 存在原始的字符串拼接参数且没有校验参数合法性,成功地进行了一次提权攻击 :D
UDF 是每届 TiDB Hackathon 的常客,每次都会有 UDF 的 proposal,这里面也包括我在 2018 年参赛时实现的基于 lua 的 UDF。当时的实现非常粗糙,我们在 TiDB/TiKV 上直接起了一个 lua vm,然后允许用户以 CREATE FUNCTION 的语法把 lua 函数上传到 TiDB 并保存在 PD,同时支持用户通过 call_lua(fn_name, args)
的语法来调用 UDF。TiDB/TiKV 的执行器会从 PD 加载函数 body,并通过 lua vm 执行。这个方案有许多问题,以至于只能是一个 demo,始终无法落地:
lua UDF 这个项目也因此很遗憾没有拿奖。一个没被采用的队名:MAKE UDF GREAT AGAIN!
这次重新捡起了 UDF 这个 topic,主要是时机相比与 18 年已经成熟了,有两个重要的情况发生了变化:
这次再用同一个 topic 参赛,但我们不再是 idea driven hackathon,更多关注的是方案可落地,让 UDF 这个选题从未来的 TiDB Hackathon 中消失,在设计之初,我们就定下了若干目标:
为了不引入过多过重的 runtime,增加维护的心智负担,我们选择了 Wasm 作为解决方案。Wasm 本身我不过多介绍了,感兴趣的同学可以在很多地方找到详细的介绍。最关键的是,Wasm 拥有我们想要的所有 feature。Wasm 是面向浏览器的沙箱环境安全执行设计的一种 low-level 指令,同时也兼备接近 native 代码的高性能,之后也被扩展到非浏览器的平台运行。(其实我觉得这玩意早该有了,但确实还是要依赖各大巨头合作才能定义出一套大家都接受的标准)
选定了 Wasm 作为技术方案以后,剩下的工作就是选择一个合适的 runtime,并且嵌入到 TiDB/TiKV 的执行器中。由于同时需要兼容 C、Go、Rust 语言,我们选择了 Wasmer 作为我们 demo 时的 runtime,并且使用了 llvm backend 达到了接近 native 的性能。
1 |
|
假设用户先基于 emscripten 工具链实现了一个简单的函数 aplusb,由于这里的类型完全是 Wasm 原生类型,因此也不需要做任何额外的处理。
1 |
|
用户可以通过 CREATE FUNCTION 命令将这段 wasm bytecode 传给 TiDB:
1 |
|
创建后,UDF 的 Wasm bytecode 会被存在一张系统表中,在某个节点首次执行后会被编译执行并且缓存在该节点上。
当然对于大段的 bytecode,这种方式其实不友好,可能后续需要提供一些上传工具。(不过这并不是 hack 需要考虑的事情
我们分别用 Wasm UDF 和 TiDB/TiKV native code 实现了一个叫 [nbody](add builtin nbody · tidb-hackathon-2020-wasm-udf/tidb@bbcf0d5 (github.com)) 的函数用作性能测试。我们很意外地发现 UDF nbody 比 rust nbody 大致相近(符合预期),但居然比 go nbody 快一些 (说明 golang 辣鸡),猜测是 allocator 的问题。这个实验也充分证明了 UDF 的高性能。
这其实是我们在 Wasm 上踩坑最多的地方,Wasm 的接口比较 low level,因此一些高级语言的数据结构(其实从需求上来说主要就是 string)映射到 Wasm ABI 会有不同的表示,我们还是需要为每种语言定制一下工具链来生成符合我们预期的 Wasm bytecode。
我们分别用 rust/golang/C 实现了 UDF 的功能,在尝试 Java 时,我们踩了比较大的坑,感觉 Java compile to Wasm 的工具链都比较不成熟。目前来看,Wasm 生态比较好的其实是运行时更轻量的静态语言。
Wasm 本身是个安全的沙箱执行环境,我们可以主动提供一些 API 给用户调用,并且配合权限认证。为了演示这个功能,我们实现了一个 HTTPGet 的函数,同时对接了 TiDB 本身的 Privilege 系统 —— 有 UDFNetworkPrivilege 权限的用户才能顺利创建和执行这个函数。这其实给 TiDB 与云生态结合提供了很大的想象空间。
最后我们在 Demo 的时候演示了一个非常 fancy 的东西:我们复用了上一届 Hackathon 二等奖的一个成果,他们做的是基于 Wasm 让TiDB 运行在浏览器里,然后我们直接复用他们的 Wasm 实现了让 TiDB 跑在 TiDB 里
1 |
|
我们的参赛主题写的是 TOT(TiDB over TIDB),事实上我一开始是想展示三种 TOT 的 demo(很遗憾的是我们最后只实现了两种):
至少在互联网公司的数据库应用中,存储过程已经是基本被抛弃的功能。存储过程有很多众所周知的缺点:
而基于 Wasm 的存储过程,可以基本避免上面提到的缺点:
这样自由又强大的 ”存储过程“ 已经接近于数据库内部的 Serverless 了,相比直接用 Serverless ,做在数据库内部有什么好处,这又是另一个很大的 topic 了,下次有灵感了再写(🕊)。
最近经常学习 Manjusaka 老师的 Blog,感受了 ePBF 给内核可观测性带来的变化。事实上 Wasm 也可以做到类似的事情。除了实现存储过程,Wasm 还可以用于用户自定义 Trigger。这个 Trigger 不仅仅可以在数据修改时执行,在如获取 TSO,下推计算、数据复制,等所有具有观测价值的地方都可以进行一些埋点对 UDF 调用,而在 UDF 内部可以灵活高效地采集监控信息,甚至可以通过受限 API 跟用户生态或者云生态内的其他系统汇报采集到的信息,这可以给数据库系统带来巨大的可观测性提升。
总的来说是一次非常充实的 Hackathon,在一次 Hack 的过程中我们不仅需要跟 TiDB、TiKV 打交道,还了解了各个语言工具链特别是大量 Wasm 周边生态有关的知识,工作量非常的大,感谢队友大腿们 breeswish,Fullstop000 和 Hawkingrei 的带飞。
在给评委答辩画饼的时候,事实上我也感受到了 Wasm+Wasi 在服务器领域的巨大潜力,如果 Wasm 能在更多系统中大规模落地的话,能在保证数据安全的前提下给用户带来更强更灵活的定制能力。这个 Hackathon 项目也是为这个过程做了一点微小的贡献~
Protobuf 是 Google 整的一个序列化/反序列化框架,性能不算很好不过用的人比较多,各个语言的实现也比较全,其中 golang 的版本是 google 官方维护的 golang/protobuf,但由于比较保守,对各种新 feature request 不太感兴趣,所以社区广泛使用的是一个 fork 的版本 gogo/protobuf,gogo 版本不仅在性能上做了很多优化,而且提供了很多 extensions,可以让生成的代码更符合 go 开发的习惯。
这篇 blog 记录的是在使用 stdtime extension 中遇到的一个性能问题排查过程和修复方案。stdtime extension 可以把 Google 提供的一个公共库中 Timestamp 的类型定义转化为 golang 标准库 time.Time
的定义。
线上开发某个服务的时候用了 gRPC 和 gogo/protobuf,然后其中的 message 定义大概是:
1 |
|
大概有几千个服务会调这个 RPC,每次返回的 as 的长度大约是 10000 左右。在测试时,从客户端观察,几乎每个请求都要耗时 20 秒以上,但在服务端观察到每个请求的处理时间在 100ms 以下。在排除了网络故障的可能性以后,我开始怀疑是 RPC Framework 的问题。
Golang 自带的 profile 框架 pprof
是非常好用的,简单跑了个 mutex profile 以后,观察到
发现阻塞时间基本在 proto 包里的一个 Mutex
上。
简单翻了一下代码
1 |
|
看起来是为了让每个 Message Type 都只会产生一个 *marshalInfo
,用了一个全局的 map 和一个全局的 Mutex 来保护。产生大量 message 的时候,这个 Mutex 成为了瓶颈。这段代码在 gogo/protobuf 和 golang/protobuf 同时存在。
当时觉得已经定位到了问题,并且修复方案也很简单,用 RWMutex 做个 double check 就好了,测试过优化明显后,顺手给 golang/protobuf 交了一个 PR。
很不幸的是 golang/protobuf 的 maintainer argue 这个函数只会被调用少数次,有几个 message 定义就回被调用几次,与运行时产生的 message 数量无关。并且给出了复现例子,于是只好继续深入定位问题。
在 demo 中做了若干次详细的试验以后,发现这个 bug 确实只能用 gogo/protobuf 复现,并且必须打开 [(gogoproto.stdtime) = true]
的选项才会产生。
在打开这个特性开关后,gogo/protobuf 需要引用 google Timestamp
的定义来反序列化数据,再转化为 time.Time
,Timestamp
的定义是由 protoc-gen-gogo 生成的,包路径为 github.com/gogo/protobuf/types
,然而所有生成的代码都需要反过来依赖 github.com/gogo/protobuf/proto
,所以会形成循环依赖。为了解决这个问题,gogo/protobuf 在 github.com/gogo/protobuf/proto
包里 mock 了一个 timestamp,只通过 struct tag 定义了最基本的序列化格式,而缺失了一些关键的方法,导致没有满足 newMarshaler
和 Marshaler
的 interface,同时 protobuf 为了满足向后兼容性,入口函数 Marshal 依然接受不满足 newMarshaler
,Marshaler
的参数,只是走了最慢的路径。
1 |
|
由于同时涉及了代码生成和循环依赖问题,这个问题的正确修复方式可能需要涉及到很大的重构,比较简单的 Workaround 有:
github.com/gogo/protobuf/proto
中依赖 github.com/golang/protobuf/ptypes
中的 Timestamp
来避免循环依赖,但会导致 gogo/protobuf 依赖 golang/protobuf,仍然不是好的解决方案。github.com/gogo/protobuf/types.Timestamp
中 copy 更多代码到 mock 的 github.com/gogo/protobuf/proto.timestamp
中目前提了一个 issue,不过 gogo/protobuf 的维护也不太活跃,在这个 issue 解决之前,建议不使用可能会触发该 bug 的 stdtime,stdduration,customtype 等 extension。
]]>Haystack 和 F4 是 Facebook 为了解决照片存储的场景开发的一套小文件存储系统。整个设计非常简洁(褒义,虽然简洁到让人怀疑这也能发 OSDI),但是却把每个部分的设计和考虑解释得非常清楚。读完 GFS 会感觉有不少未解之谜在 paper 中没交代清楚,但读完 Haystack 和 F4 就感觉异常通顺。Facebook 一开始开发了 Haystack 是为了覆盖整个照片存储场景,后来发现了温存储场景可以优化的地方,又开发了 F4 将冷数据从 Haystack 中剥离出来,单独存储,并且 F4 的 paper 中描述了整套 BLOB Storage 系统同时修改了一些 Haystack 的设定,因此将这两篇放在一起讲。F4 也分享了很多 Facebook 在这套系统的设计和使用上的很多经验,值得学习。
Needle in a haystack
Needle 是 Haystack 中的基本存储单位,英文翻译是针,Haystack 的英文翻译是草垛。出于好奇 Facebook 为什么取了这个名字的目的去搜了一下,发现这是一句类似于“大海捞针”(草垛里捞针)的常用短语,这么理解的话这个名字对于一个存储海量小文件的存储系统就非常形象了~
在开发 Haystack 之前,Facebook 使用基于 NFS 的设计方案。每个小文件直接对应 NFS 上的一个物理文件,在 CDN Cache Miss 的文件会直接通过 Photo Server 落到 NFS 上读,这种方案的缺陷非常明显,就是小文件给文件系统带来的太多元数据。POSIX 文件系统在文件节点上存储了大量 Facebook 的场景下不需要的信息(如权限信息等),每个 INode 都要占据大约 500 byte 的空间,导致在大量小文件的场景下,文件系统无法将元信息全部缓存到自己的内存中,访问数据的时候,除了必须要的一次数据读取的磁盘 IO,在获取元数据以定位真实数据位置的过程中也需要经过若干次磁盘 IO,这是基于 NFS 的系统导致图片访问慢的主要原因。
Haystack 的优化目标非常明确,就是砍掉无用的元信息,压缩元信息到足够小并全部加载到内存中,将对单张图片的访问精确地缩减为一次磁盘 IO。
Haystack 的优化思路也非常的简单,既然小文件的元信息太多,那么就把大量小文件打包成大文件再存,自己维护小文件需要的少量 元信息。在 Haystack 中,存储的小文件及其元信息称为 Needle,而打成的大文件包称为 Volume。Haystack 的核心部分其实就是这个单机的小文件存储引擎。
Facebook 的架构里用户除了访问 CDN 以外,也可以跳过 CDN 直接访问数据,这两种请求最终都会由 Haystack Cache 处理(猜测区别仅仅是内部和外部 Cache)。Haystack Cache 就是个很平凡的 Cache 逻辑,以 Photo ID 为 key 维护了一个分布式哈希表,如果请求的照片没有缓存,就从底层的 Haystack Store 读取数据,并且对满足一定条件的查询结果进行缓存。
这块唯一需要注意一些的就是这个缓存条件,当且仅当满足两个条件的时候,Haystack Cache 才会进行缓存:
Haystack Store 是最核心的模块,也就是 Haystack 的存储节点。Haystack 放弃了原生的 POSIX 作为小文件存储的接口,但沿用了 POSIX 文件系统的底层,自己基于这个底层开发了一个单机的小文件存储系统,并运行在每块 Disk 或者每个节点上、
每个 Volume 有唯一的 Volume ID,标识一个 Logical Volume,但为了数据可靠性,每个 Logical Volume 在集群内会有三个副本,这些 Volume 实体称为 Physical Volume,在单机的存储引擎中。这些 Physical Volume 是真实的 POSIX 文件系统下的文件单位,分散在 Haystack Store 的节点中。
每个 Volume 会存储数百万张照片,并由三个文件组成,Data file 和 Index file 和 Journal file(后续加入)。Data file 由连续的 Needle 组成,每个 Needle 除了存储图片本身的数据以外,还存储了一些额外的元信息,其中比较 重要的有照片的 key 和 alternate key,图片的 size 和 checksum,以及一个删除标志位。
key 和 alternate key 用于 Facebook 场景下的二层索引,因为 Facebook 对每张照片存了四种不同 size 的图片(包括缩略图,小图,大图,原图),因此每张照片有一个主键,然后再通过 alternate key 对应到需要的尺寸的图片。
根据 key 和 alternate key,Haystack store 的每个节点在内存中为每个 volume 构建了一个双层的索引,用于快速找到对应的 Needle 在 data file 中的偏移量,并缓存了 size 信息减少一次读取元信息的 IO。而 Index file 是这个索引文件的一个快照。
一个 Volume 支持数据粒度的 Read,Write 和 Delete 操作,实现在了解数据的定义之后都非常的 trivial,直接通过下面的伪代码展示,但有一些场景需要考虑。
Index file 是定期 dump 到磁盘中的,因此宕机时会丢失数据,需要恢复,对于新写入的数据这非常简单,因为遗失的数据总是在 data file 的尾部,从 index 中最高的 offset 开始从 data file 恢复这些 meta 信息即可。但是对于删除的数据无法简单地恢复,在旧版本的 Haystack(即 Haystack paper)中,删除是通过修改 data file 中 needle 的标志位来完成持久化的,而在新版本(F4 paper 中提到的 Haystack),每次删除文件仅需要在 journal file 中添加一条记录,这是磁盘 append 操作因此非常快,而 Index 在宕机恢复时仅需要将 Journal file 和 Index file 做一个 merge 即可。
1 |
|
为了避免垃圾数据过多,volume 还会根据一定条件触发 Compaction,回收已经被 Delete 或者被 Write 覆盖的数据。
除了单个 volume,我们考虑一下如何在多个 Physical volume 之间保持一致性,Haystack 的答案似乎就是根本不管。由于 Write 和 Delete 非常幂等,并且 Photo Store 稍微不一致也不是特别要紧,Haystack 选择添加监控并且手动处理一些不一致的异常情况。
这是个 paper 笔墨很少,但十分重要的组件,它存储了所有的元信息,比如 volume ID 到 physical volume 位置的映射。它负责调度用户的请求,包括负载均衡写请求的 logical volume,负载均衡读请求打到哪个 physical volume 等的调度。为了避免一个 volume 无限增长造成运维困难,directory 会在 volume 大小达到一定容量时将 volume 标记为 read-only。
这个就是个健康检测后台任务,定期给所有存储节点发请求,观测到一个 volume 发生异常时标记为 read-only 并找运维人肉处理。这不会影响服务的整体可用性,因为写请求可以打到任意一个 volume。
F4 是 Facebook 在 Haystack 之后又搞的一个 Warm blob store,这个 warm 就比较魔性,让人想起星巴克的中杯、大杯和超大杯。不过事实上,F4 存储的确实不是冷存储,而是 Facebook 的一些 long tail 的照片,他们仍然会被获取,但是频率较低,也很少被覆盖或删除。相比于一些获取数据需要以天为单位的真正的 cold storage,F4 仍然要求对数据的获取在百毫秒级的时间内完成响应。
在 Haystack 中的数据因为三副本的原因有比较高的存储成本,F4 的设计目标主要是在保证数据安全的情况下降低数据的存储成本。一点可以利用的性质是从 Haystack 导入 F4 的照片很少会被删除,我们可以认为整个 Volume 都是不可变数据。
Haystack 和 F4 的接口完全保证一致,通过 router tier 对用户隐藏具体实现。
在数据导入的时间点上,Haystack 基于底层硬件设备(HDD)的读写能力和 BLOB 的使用情况统计进行设计,以 80 IOPS/TB 作为分界线对统计结果进行划分,并确定了三个月的分界线,即对于 Facebook 的大部分 BLOB 数据来说,在经过三个月的时间以后,访问频率就会降到显著低于 80 IOPS/TB,以至于使用廉价的 HDD 作为存储介质依然可以提供不影响用户体验的服务。这个设计过程也是充分地利用了软硬件一体的设计思想。
为了减少空间的使用,F4 引入了 EC(erasure coding)的技术。n:m 的 EC 可以将一份数据切为 n 份,并且构造 m 个冗余块。在这 (n+m) 个块中任意丢失 m 块数据,都能通过剩下 n 个块恢复。因此保障数据安全仅需要 (n+m)/n 的空间。Facebook 选择了 10:4 的比例,比起三副本来说,可以节约大量的空间。为了异地灾备,Facebook 在两个不同的集群之间再次通过对两个 Volume XOR 编码,并将这份冗余块备份到第三个集群中,通过 1.4 * 1.5 = 2.1 倍的空间完成了异地灾备级别的数据可靠性。
在 F4 中,一个从 Haystack 导入的 Volume 前会经过 compaction。在 F4 中,Volume 里较小的 index file 仍然通过三副本,但是占 Volume 主体的 data file 完全通过 EC 来保障数据的可靠性和可用性。每个 data file 会按固定的大小(1GB 左右)切成连续的 data block,然后为每 n 个 data block(称为 strip),生成 m 个 parity block,不足 n 个的部分填零补全。每个 Strip 对应的 n+m 个 block 会被分布在不同的机架中,来保证机架级别的容错域。
架构上分为了五种类型的节点,并将这些节点打包成了一个 F4 Cell。
虽然 F4 不支持修改数据,但为了用户数据的隐私,允许对数据进行删除。
在最开始的设计中,F4 的开发者计划在 F4 中保留 Haystack 中的 Jornal file 作为文件的删除记录,并保留 compaction 的策略。很快他们发现在 F4 设计中留这个可变的因素会大大增加设计复杂度。因此 F4 换了个删除的思路,将所有 BLOB 在导入 F4 前用每个 BLOB 唯一的秘钥加密,并将秘钥存在外部数据库中,如果需要删除数据,只需要在外部数据库中删除这个秘钥,就能让加密的 BLOB 无法通过任何手段恢复原来的数据,从逻辑上做到了 BLOB 的删除。
这个词翻不太来,大约是指备份的数据相比原始数据的比例。在 Haystack 中这个数字是 3.6,由三备份和 1.2 倍的 RAID 组成,在 F4 中,这个数字被降低到了 2.1。不过这个可以节约空间的前提是建立在 F4 中的数据删除比例比较少,根据 Facebook 的统计结果,对于大于三个月的 BLOB,这个结论成立。
]]>这是 CPython 源码阅读系列的第一篇,我也不知道能坚持多久,或许连这篇都写不完(如果你能在网上看到这句话,说明至少第一篇写完了)。
Python 是一门非常强大的动态语言,语法特性多且优美,非常符合人类直觉,是我最喜欢的语言之一,再加上 CPython 做的优化非常少(笑),不像某些 JS 引擎如 V8,为了性能各种 hack 技巧太多不适合阅读学习。
对 Python 比较精通以后,自然而然会产生疑问,那么强大的 Python,是怎么通过一门语法特性非常少的静态语言 C 来实现的呢?
当然,阅读 CPython 源码的第一步,就是让你抛弃 C 语言是静态类型的错觉。
本系列无明显顺序,更类似于个人阅读中的笔记,可能有错误,欢迎指出。
本系列文章要求阅读者:
作者开始阅读源码时,使用的 Python 版本是 Python 3.7.0a0
,最新的 commit 号为 984eef7d6d78e1213d6ea99897343a5059a07c59
。
本文涉及的核心文件是
一切都要从 Hello World 开始,CPython 源码阅读的 Hello World,当然是从 PyObject
开始。
首先,在 object.h
里找到 PyObject
的定义:
1 |
|
Python 中的一切对象,都至少保存了以上属性,这也是为什么在 Python 中,哪怕是一个简单的 0
,也比 C 语言占用了更多内存的原因。
_PyObject_HEAD_EXTRA
这个宏是全空的,应该是为了未来可能的修改而保留修改空间,可以先忽略它。
观察一下 ob_refcnt
和 ob_type
。
Python 是一门自带 GC 的语言,而 Python 的 GC 是以引用计数机制为主的,ob_refcnt
保存了 Python 每个对象被引用的次数。
在 object.c
中实现了一个函数 Py_IncRef
,这个是暴露给 Python runtime embedders 的管理 PyObject
引用计数的接口,而源码内部的实现基本调用的是 Py_XINCREF
(附录 0) 和 Py_INCREF
这两个宏。
1 |
|
我们先假装没看到一系列的强制转换,那么这两个宏的作用就分别是 safe 和 unsafe 地增加一个 PyObject 的引用计数。
关于 ob_refcount
还定义了一些别的宏,具体的运用方式想放在 GC 的章节一起看。
ob_refcount
的类型是 Py_ssize_t
,这个类型在我的系统上等价于 long
,再加上为了为了效率,维护引用计数的时候显然不会作边界检查,这就意味着如果你的一个对象引用超过 LONG_MAX
的话应该会溢出,然而虽然很想尝试一下,但我并不知道怎么在爆 Memory Overflow 前让一个对象的引用计数超过 long
。
ob_type
显然保存着对象的类型信息,然而在观察其具体实现之前,我们可以看一下紧跟着 PyObject
的另一个结构体的定义,PyVarObject
。
根据注释,PyVarObject
用来存储变长的 python 对象(如 list 等),熟悉 C++ 的同学可能已经忍不住脑补出以下代码:
1 |
|
那我们再来看它在 CPython 中的实现:
1 |
|
看起来非常反人类的做法,难道每次想操作 PyVarObject 中 ob_type
的时候都要多一层 ob_base
吗?
然后就是一个比较神奇的操作,而且唯有 C 这种结构体严格映射内存结构的语言才能做到(附录 1),对于 C 来说,PyObject ob_base;
的内存结构和 _PyObject_HEAD_EXTRA Py_ssize_t ob_refcnt; struct _typeobject *ob_type;
是完全等价的,那么可以直接把 PyVarObject*
类型的对象强制转换成 PyObject*
类型的对象,然后直接当成 PyObject* 类型操作。
现在我们可以回过去看代码中对于 PyObject
某段注释:
Nothing is actually declared to be a PyObject, but every pointer to
a Python object can be cast to a PyObject*. This is inheritance built
by hand. Similarly every pointer to a variable-size Python object can,
in addition, be cast to PyVarObject*.
PyObject
不是 Python 中的任何一种类型,但是任何任何 Python 对象的指针都能被 cast 成 PyObject*
,相当于手动实现了继承(附录 2)。同理如 PyVarObject*
。
为了方便之后 Python 中对象的定义,object.h 中定义了两个宏
1 |
|
利用这两个宏来达到继承 PyObject
和 PyVarObject
的作用。
(看到这里,你可以再思考一下 C 究竟是不是静态语言,至少有没有被人当静态语言用)
为了获取 PyObject
和 PyVarObject
中的基础变量,object.h
定义了三个宏
1 |
|
任何 Python 中的对象都能通过这些宏来获取对应的信息来进行读写。
Python 中一切皆为对象,类型也不例外。
现在让我们回到被我们跳过的 ob_type
,这是一个指向 PyTypeObject
对象的指针。
1 |
|
PyTypeObject
继承了 PyVarObject
,这个对象非常的复杂,在之后的文章再详细介绍。
[0]: 关于 do ... while(0)
的解释,之后的贴代码的时候可能会省略该部分。
[1]: 参见 https://stackoverflow.com/questions/2748995/c-struct-memory-layout
[2]: 虽然 C 语言里没有继承的语法,不过这个概念完全是继承,再加上作者钦定了,所以之后将直接用「继承了 PyObject
」 这种语言来描述这种行为。
HaScheme 基于 Stack 构建了项目,如经典的解释器架构一样,将项目主要划分为了三个模块,Lexer
,Parser
和 Interpreter
,输入的 Scheme 代码首先通过 Lexer 转为 token 序列,然后通过 Parser 将 tokens 转换为 AST,最后由 Interpreter 解释 AST 执行。
HaScheme 参考了很多 Write yourself a Scheme in 48 hours 这个教程的内容,这个教程非常初学者友好,不过这个教程实现的文法作用域有一些 bug。HaScheme 中修复了文法作用域的问题,并且扩展了语法特性。
Lexer 和 Parser 是写的最舒服的一部分了,主要是 ParseC 实在太好用了,可以用原生 Haskell 代码直接描绘语法生成式的结构,并通过 Parser Monad 很轻松的转换为想要的数据结构。
ParseC 是自顶向下分析的,遇到不匹配的情况需要用 try
回溯,比较遗憾的是对于需要回溯的情况,ParseC 无法正确地输出报错信息,对于这部分我也没有特别处理,所以 Parser 的报错系统非常简陋。
Haskell 原生的数据结构就是 AST 的形式,配合 Pattern Match 解释起来简直爽到起飞,不过 Immutable 的特性就不那么令人愉快了,应该是我姿势水平不足的缘故,不少操作(如 define
,set!
)在解释的过程中是会对环境造成影响的,一开始我尝试用 State Monad 来实现环境,大概思路是
1 |
|
不过这样的话,非常困扰于文法作用域的实现,因为对于一个作用域,必须能引用父级作用域的中的变量,也可以支持 Variable Shadowing 而不对父级作用域造成影响。
最终的实现参考了教程,即基于 Data.IORef
来实现变量。
1 |
|
等于在命令式语言中保存了变量实体的指针,这样在新建一个子级作用域的时候,可以简单的拷贝当前环境,子级作用域也能对当前环境的变量进行读写,同时在新增变量时只对新的环境进行修改而不影响父级作用域的环境。
这部分就是教程实现错误的地方,教程中,对于 define
一个同名变量
1 |
|
他会简单的修改修改变量的值为新的值,这回导致对父级作用域的变量进行修改,而这时候正确的行为是掩蔽父级作用域的同名变量,即新建一个 IORef
替换当前的 IORef
而非修改当前的 IORef。
教程中的实现会产生如下 bug
1 |
|
正确的输出应该是 7,然而执行的结果却是 9,因为嵌套内部的函数参数 x 修改了外部 x 的值。
在我的实现中
1 |
|
删去了变量是否存在的判断,一律插入新的 IORef
,修复了这个 bug。
Haskell 是支持一部分命令式的语法的,所以我实现了 begin
语句 和 while
语句,不过在我的实现方法中很难正确的实现 while
语句,所以我用了一个很 Hack 的实现,在 Parser 的阶段将 while
语句 parse 成 if
语句和 尾递归调用的语法糖,这种实现有很多问题比如栈溢出等,所以其实没有正确实现这个特性。
1 |
|
没有 REPL 的解释器是不完整的,这里非常感谢 Haskeline 的作者,借助 Haskeline 强大的 API,实现了很好用的 REPL。
实现了历史记录、自动补全、语法树查看等特性。
]]>CamusAPI 这个项目的初衷是希望建立一个清华内部的校园开放 API 平台,供校园应用的开发者使用,不需要处理复杂的爬虫逻辑和页面逻辑,将学校的系统封装成一层清晰完整的 RESTful API 系统。
由于本来就有比较丰富的开发经验,知道架构对整个项目的重要性,所以一开始的项目架构是我独立完成的,将项目根据功能划分成了好几个模块,尽可能地减少模块间的耦合。此外,考虑到去年开发紫荆之声时代码风格的丑陋和整个团队开发风格的不一致,我又提前配置了 ESLint 和 travis-ci,确保整个团队的代码风格完全统一且严谨,对诸如每个函数的行数,每个文件的行数,最大缩进层数也做出了严格的限制,不通过 ESLint 的 commit 不会被合并到主分支,虽然这会增加一些开发时的成本,但是比起团队成员互相维护代码,以及可能的重构时带来的便利,这些成本是微不足道的,也很感谢组员的配合。
之前的开发中经常遇到本地开发完到最后不会部署的情况,所以这次在架构完一开始就配置好了 Docker,并且每天从 master 分支部署一次,这样对联合调试也帮助很大。
测试是软件工程中非常重要的一环,比较遗憾的是我们组由于开发周期非常紧以及测试相对来说困难,没能采用 TDD 的开发模式,不过在最后的一周我们补全了必要的测试,我们核心模块的测试覆盖率超过 95%。不过这次被坑的是性能测试,由于对 NoSQL 数据库的原理不够了解,一开始设计数据库的时候踩了坑,使用了不合适的组织方式,也因为没有提前性能测试,直到接近 ddl 的时候才发现性能很差,经过重重排查后发现是数据库的锅,连夜重构,索性前期架构模块划分合理,数据库操作被包在一个模块里,所以重构没有遇到很大的困难,吸取的教训是每添加一个功能都该进行一次性能测试,这样既能实时 profile,也能及时发现不合理的操作避免无谓的重构。
作为 API 小组,我们的文档非常重要,不过我们组都不是很擅长写文档,这里感谢马子俊同学承包了几乎所有的文档工作,写出了完整的接口文档供其他组的同学使用,并受到了好评。
不过担任 API 小组也让人体会到了甲方的坑爹之处,毕竟别的组的项目都是自己提需求,自己伪装成用户,可以什么好做做什么。起初我组只打算给一到两组提供 API,甚至没有的话完全可以自己搭建客户端来展示,后来在老师的要求下为其他所有组提供相关的 API,不得不说这并不是个愉快的体验,我组面临二十来个甲方提需求不堪重负,甚至相当一部分需求是受限于学校系统无法完成的,虽然让他们自己做他们也完不成,但是要求我们提供的时候丝毫没有觉悟。此外我们原来希望联合的小组开发进度不要太快,这样我们可以比较优雅地组织我们的代码,但部分强力的组加入后我们不得不赶工。出于尽可能让他们能够稳定开发的目的,我组不得不先以功能为第一目的,在安全性、性能等原本很重要的地方做一些让步以适应他们的开发。而且人多口杂需求多,在某些新加入的组的要求下被迫做出一些对已经稳定的 API 修改接口的行为,从某种程度上来说我们拉低了他们的开发进度,他们拉低了我们的代码质量。
这个项目从功能上来讲并不是我开发过的最复杂的应用,实现的功能也比较基本,但是从组织模式上来说是最工程化的,也在合作中收获了很多,感谢软工课给我的机会。最后,对课程的建议主要是前期文档实在有点多,很多实现细节不可能在开发前就确定,文档也随着每个迭代更新或许更合理。
]]>