Notes on Software Engineering at Google (1)

前言:这本书看的断断续续的,如果不留点笔记可能很快就忘了,所以还是要记录一下。本书作者免费发布 pdf 版本,可以在 Software Engineering at Google (一下可能简称为 SWE)免费下载。因为是个笔记,所以行文可能逻辑比较断裂。

什么是软件工程

Nothing is built on stone; all is built on sand, but we must build as if the sand were stone. —Jorge Luis Borges

第一部分主要是在讲软件工程的定义,Software Enginner (以下可能简称为 SE)与 Programming 的区别。

最核心的一个想法是,你的代码的生命周期是多久?Google 的新项目往往默认项目会存在十年——这样一来,基础组件升级、依赖变化、团队组成都可能会变化,从而最终影响决策、影响设计。

这一点上国内很多项目肉眼可见的做的不好,最典型的莫过于百度的博客。当然这不意味着个 Google 的产品做得多好,但是这个思维方式确实很有用。当你设计的时候想的是要用十年,很多设计和决策就就和短期项目完全不同了。

我猜可能有很多人会有不同意见,比如像下面这个图里的例子

我觉得也并非不可调和,业务驱动的产品,很多时候就是需要快速试错,上线一个 demo 能用就行——这种情况就是定位为 demo,定位为 programming 就好了。另一方面,这也和从业人员对 SE 了解太少,公司无法做到将 SE 融入到日常有关,如果 SE 就是 Infra 的一部分,快速 demo 与 SE 并不冲突,甚至能相辅相成,自然是最好的——侧面上看,从 Google 出来的人,没有一个不在夸内部 Infra 好的,其他大部分公司则少很多。

时间与变化

项目的维护成本会随着项目的生命周期增长而飞速增长。

从一个较长的时间尺度看,软件的变化是一定的,因为你的依赖,无论是显式依赖的还是隐式依赖总会变,除了技术原因,因为商业原因也可能会变。

如果项目一开始没有做好“拥抱变化”的准备,那么真的到了需要变化的时候是非常痛苦的,主要是三个原因互相影响:

  • 项目之前没有做好这方面的准备,所以可能存在一些隐式的假设
  • 工程师没有这方面的经验
  • 变化的规模很大,因为这是数年的代码要一次性的改变,而不是逐步的渐进升级

然后因为考虑到上面这些的难度,很多公司/项目大家最后的决定就是:别动它了。或者,可能是低估了难度,升级碰到种种问题,完成一次之后形成一个经验——以后别动它了。

所以 Google 的经验就是,大型项目最最重要的工作就是维持项目的“可变性”,避免项目变成一坨巨大的不能改动不能升级的东西。

这部分让我想到陈皓(左耳朵耗子)的一篇文章:开发团队的效率,里面提到了集中开发模式,其中“‘WatchDog式’软件开发”和“‘故障驱动式’软件开发”和这里说的“代码无法变化”很像。其实就是原有代码动不了,那么只能是在外面整个辅助的“watchdog”(好听一点的话,也可以说是包一层),或者出了问题在针对性的搞一搞,没问题千万别动。

确实在很多情况下也不失为一个可行之策,但是只能用于短时间的搞,因为长此以往软件的复杂度显然会层层上升,最后没有人能够理解整个系统,软件生命也就快走到尽头了。

海拉姆定律(Hyrum’s rule),人们会依赖所有你的 API 的所有可被观测到的特性,即使这些特性不是你文档里承诺的特性,包括 API 的时间、空间的种种隐藏表现。

这个定律之于软件开发就像是熵增定律之于热力学。

Hash Table Ordering

这里有个非常好的例子,就是 Hash Table 的顺序,因为这个例子很好,所以我在原文之外多补充一点。

下面是在我的一个开发环境运行 Python3 的结果:

粗看起来,每次运行结果是固定的,虽然不是按照我们输入的顺序输出,但是保持了一个神奇的规律性。

然而,如果你写代码只实验到这一层就下定论,毫无疑问你将会整个大事故,因为:

也就是说每次 Python 解释器重新运行时,Hash 的结果都可能会发生改变,为什么要这样?

其实从 Hash 的本质上讲,你需要的只是一个 Hash 函数,比如这样:

然而根据这个函数,其实你可以轻易构造出很多“哈希碰撞”的情况:

这样一来,只要构造出几万个同样哈希的字符串,把它们提交给服务器做哈希表, 就能用很低的成本将服务器打瘫了。

这个成本具体有多低呢?依2011年的实验数据,攻击一台基于Java(Tomcat)的服务器时,仅仅需要6KB/s的流量就能打瘫一颗 Intel i7 处理器,1GB/s的流量可以打瘫 100000 颗 Intel i7 处理器,性价比远超TCP半开连接等传统的拒绝服务攻击。

最容易的解决方案,其实和前段时间的“log4shell”很像——不要信任用户输入,或者限制单个 Hash Table 的大小、限制输入的规模等等,但是这个方法肯定不 Scale——设计这个限制的开发者一旦离职,下一个接手的人一定会认为这是什么神经病整这个?!转手就删了~

根据生日悖论我们可以知道你不可能设计一个精妙的算法能够避免哈希碰撞,只要攻击者掌握了算法的细节,就一定能够设计出一个碰撞的攻击。

因此现在的低于方法都是在算法中加入一个随机种子,放每次哈希结果产生一定随机性,这样攻击者知道算法细节,但不知道这个随机种子,也就无法构造碰撞了。

反过来攻击者的攻击目标也就在刺探这个种子上了,这里不再详述,可以看这篇文章:什么是哈希洪水攻击(Hash-Flooding Attack)?

回到 SWE,你可以看到 Hash Table 的 Ordering,从一开始有序,到现在无序,那么就一定是稳定的么?不同的语言、不同的编译器、不同的数据结构还会不会有变化?放在一个很长的时间尺度上,这些都无法保证。

那么,既然软件变化这么难,有没有可能把一开始的目标定成,我们的软件,设计好就不改变了?我花海量的时间做提前的用户调研、业务调研,然后软件使用底层尽量不变的技术(比如纯 C 的库变化往往要少,或者只依赖 POSIX 这种很稳定的东西)一次成功,再也不改了?

想想 SSL heartbleed、想想 Meltdown and Spectre,就算需求没有变化,底层也可能会有安全问题导致的升级和变化,所以赌我的软件永远不变是风险极大的(基本上可以说是不可能的)

此外,因为新硬件、新算法或者是底层技术的发展,底层库或者基础设施可能效率会越来越好,如果你的软件持续不更新、不随之变化,那么可能在效率上就会被淘汰。

综上,变化不是天生的好事,不需要为了改变而改变,但是拥有这种能力显然会让我们处于巨大的竞争优势。

可扩展性与效率

Codebase 的 sustainable 的定义:整个代码在整个生命周期内都可以随时安全的改变。

如果变化的代价很大,那么人们就会倾向于不去动他。

温水煮青蛙原则:问题总是慢慢变糟,而不是忽然一下坏掉了。

经验是:人们很容易设计出不可扩展的策略(政策),识别不可扩展的策略的方式——假设整个组织规模增长 10 倍、100 倍,那么这个策略还能否有效?

依赖问题

对于小项目来说,依赖升级可能就是口头的一句——“新版本发布了,请使用新版本”

很快随着规模变大,大家就发现这样不行,此时策略一般会改为——“底层的改动不能影响上层”,在 Google 这个策略叫做 Churn Rule。因为这样可以把升级的兼容性统一交给底层团队解决,底层团队会更有经验处理这些问题,而不是让上层调用者去学习研究升级里面的问题。简单来说就是达到一个效果 Expertise scales better.

分支问题

这个问题倒是很常见,Google 的策略也很出名,就是 mono repo。因为开发分支时间一长就有主分支升级的问题,合并冲突的问题,合并时主线行为变化的问题等等一系列头痛问题。这个会在后面详细讲。

The Beyoncé Rule

所有行为应该通过 CI 系统验证,不是通过统一的 CI 系统验证的话,测试再复杂、精巧,都是不被认可的,都被认为是一次性的。

知识是病毒,专家是载体,只要有好的交流方式,知识就会不端传播,就会有新的专家成长。

Google 在 2006 年计划升级编译器,在此之前他们 5 年没有升级编译器,大部分代码都在一个编译器上验证。可想而知这次升级非常困难,因此经过这次升级之后,Google 做了一个与大部分公司相反的决定——Google 将升级编译器整合到自动化系统,并且将相应的验证限定到小的变化,从此这个事情反而成了 Google 的一项优势。

所以,你越是频繁的改变你的 Infra,他就会越容易。经过不断的升级,代码将不再依赖底层的实现细节,而是更只依赖语言、操作系统的实际抽象。

左移

简单来说,事情做的越早,成本越低。

权衡和收益

重要的是形成共识,而不是完全一致。换句话说,在 Google,很可能会有人说“我不赞同你的数据/评估,但我知道你如何形成这个结论”。

因此 Google 推崇大家通过数据得出结论,而不是“我认为”、“我以为”、“因为别人”。

成本应当包含以下这些方面:

  • Financial costs (e.g., money)
  • Resource costs (e.g., CPU time)
  • Personnel costs (e.g., engineering effort)
  • Transaction costs (e.g., what does it cost to take action?)
  • Opportunity costs (e.g., what does it cost to not take action?)
  • Societal costs (e.g., what impact will this choice have on society at large?)

Google 构建了良好的分布式 build 系统,节省了程序员的时间,但是发现带来了从此无人关心程序的编译时间的问题。但总的来说,还是收益大于成本,

Revisiting Decisions, Making Mistakes

从长远来看,一切决策都可能会从一开始正确变为不正确,甚至一开始都不正确。因此需要机制来 revisit 当初的决策。如果决策是基于数据驱动的,那么因为数据改变,很容易得出之前的决策不合适的结论,如果是拍脑袋决策,就没那么容易了。

当然,总有些东西是很难量化的,这就只能靠判断力了。

本章总结

Programming 意味着快速产出 code,而 SE 则是一系列策略、实践和工具。这些不仅能够让软件与时间相抗争,还能保证团队协作的质量。

最后,如果不想一字一句的读本章的 24 页内容,那么看下面的总结就够了:

  • “Software engineering” differs from “programming” in dimensionality: programming is about producing code. Software engineering extends that to include the maintenance of that code for its useful life span. —— SE 的关键在于扩展代码的生命周期
  • There is a factor of at least 100,000 times between the life spans of short-lived code and long-lived code. It is silly to assume that the same best practices apply universally on both ends of that spectrum. —— 软件生命周期可能有 100,000 倍的差别,因此造成了策略的不同
  • Software is sustainable when, for the expected life span of the code, we are capable of responding to changes in dependencies, technology, or product requirements. We may choose to not change things, but we need to be capable. ——只有当软件可以在生命周期内随时更改才可称之为是 sustainable
  • Hyrum’s Law: with a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody. —— Hyrum 定律
  • Every task your organization has to do repeatedly should be scalable (linear or better) in terms of human input. Policies are a wonderful tool for making process scalable. ——组织内每个重复的事情都需要是可扩展的
  • Process inefficiencies and other software-development tasks tend to scale up slowly. Be careful about boiled-frog problems. ——小心温水煮青蛙问题
  • Expertise pays off particularly well when combined with economies of scale.
  • “Because I said so” is a terrible reason to do things. ——当专业知识和规模经济相结合,回报将会异常丰厚
  • Being data driven is a good start, but in reality, most decisions are based on a mix of data, assumption, precedent, and argument. It’s best when objective data makes up the majority of those inputs, but it can rarely be all of them. ——数据驱动是个好的开始,但大部分决策都是数据、假设、经验、争论的结合
  • Being data driven over time implies the need to change directions when the data changes (or when assumptions are dispelled). Mistakes or revised plans are inevitable. ——数据驱动意味着数据变化时策略的对应变化,注意复盘决策

这一章内容确实没有太多过人之处,但是能否把“正确的废话”或者“看起来并不特别的策略”贯彻好,可能就是不同公司天花板不同的一大原因吧。

Network Tracing Hands on

eBPF

提问:以下哪个内核可以运行 ebpf?

  • ZStack C74
  • ZStack C76
  • CentOS 7.7
  • ZStack experimental repo 中的内核

(分别对应了 3.10.0-693 3.10.0-957 3.10.0-1062 4.18.0-240)

答案:有三个内核可以支持

C74 是不支持的,C76 有有限的支持,C77 支持程度更好一点,experimental kernel 里是 4.18.0,对 ebpf 支持已经很完善(当然了,ebpf 还处于快速发展阶段,因此还是版本越高越完善)

https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html-single/7.6_release_notes/index#technology_previews_kernel

https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/7.7_release_notes/new_features#enhancement_kernel

检查自己安装的内核:

grubby --info=ALL

在 ZStack 管理的计算节点上安装来自 experimental 的 4.18 内核:

yum --disablerepo=\* --enablerepo=zstack-experimental-mn install kernel-4.18.0

在 ZStack 管理节点上安装来自 experimental 的 4.18 内核:

yum --disablerepo=\* --enablerepo=zstack-experimental-mn install kernel-4.18.0

查看内核编译选项

[root@dev1-4 ~]# cat /boot/config-3.10.0-1062.18.1.el7.x86\_64 | grep -i bpf
CONFIG\_BPF=y
CONFIG\_BPF\_SYSCALL=y
CONFIG\_BPF\_JIT\_ALWAYS\_ON=y
CONFIG\_NETFILTER\_XT\_MATCH\_BPF=m
CONFIG\_NET\_CLS\_BPF=m
CONFIG\_BPF\_JIT=y
CONFIG\_HAVE\_EBPF\_JIT=y
CONFIG\_BPF\_EVENTS=y
CONFIG\_BPF\_KPROBE\_OVERRIDE=y

安装 bcc,跑一个 ebpf demo

参考:https://github.com/iovisor/bcc/blob/master/INSTALL.md#rhel—binary

yum install bcc-tools

下载 bcc 源码:

git clone <https://github.com/iovisor/bcc.git>

运行 hello world(跟踪系统调用 sys_clone):

python examples/hello\_world.py

在 hello world 上稍作修改,跟踪 ipt_do_table 调用

修改 hello world,改成调用 ipt_do_table(进入 iptables 的时候)的时候打印 hello world

[root@dev1-4 bcc]# cat examples/hello\_world.1.py
#!/usr/bin/python
from bcc import BPF
BPF(text='int kprobe\_\_ipt\_do\_table(void \*ctx) { bpf\_trace\_printk("Hello, World!\\n"); return 0; }').trace\_print()

此时会发现脚本会不断打印 hello world

下面将 iptables 模块移除:

# **保存一下之前的 iptables 内容:**
[root@dev1-4 ~]# iptables-save > ww.1.ipt

# **准备一个空的 iptables 配置**
[root@dev1-4 ~]# cat ww.empty.ipt
# Generated by iptables-save v1.4.21 on Thu Sep 23 10:24:35 2021
\*raw
:PREROUTING ACCEPT [0:0]
:OUTPUT ACCEPT [69467:48787226]
COMMIT
# Completed on Thu Sep 23 10:24:35 2021
# Generated by iptables-save v1.4.21 on Thu Sep 23 10:24:35 2021
\*filter
:INPUT ACCEPT [81:6753]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [69467:48787226]
COMMIT
# Completed on Thu Sep 23 10:24:35 2021
# Generated by iptables-save v1.4.21 on Thu Sep 23 10:24:35 2021
\*mangle
:PREROUTING ACCEPT [76482:29150585]
:INPUT ACCEPT [73927:27411897]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [69467:48787226]
:POSTROUTING ACCEPT [69467:48787226]
COMMIT
# Completed on Thu Sep 23 10:24:35 2021

# **停掉 zsn,将空配置导入**

[root@dev1-4 ~]# systemctl stop zstack-network-agent
[root@dev1-4 ~]# cat ww.empty.ipt | iptables-restore

[root@dev1-4 ~]# lsmod | grep ipt
ipt\_REJECT 16384 0
nf\_reject\_ipv4 16384 1 ipt\_REJECT
iptable\_raw 16384 0
iptable\_filter 16384 0
iptable\_mangle 16384 0
ip\_tables 28672 3 iptable\_filter,iptable\_raw,iptable\_mangle`

# **卸载模块**
[root@dev1-4 ~]# modprobe -r iptable\_raw
[root@dev1-4 ~]# modprobe -r iptable\_filter`
[root@dev1-4 ~]# modprobe -r iptable\_mangle
[root@dev1-4 ~]# modprobe -r ipt\_REJECT

[root@dev1-4 ~]# lsmod | grep ipt
[root@dev1-4 ~]#

发现不会再打印任何 hello world

类似的,可以跟踪其他函数,例如网络路径中的 iprcv、br_nf_forward 等等

复杂的例子:skbtracer

首先下载代码(原来的代码有一些问题,例如没有 KBUILD_MODNAME,dropstack 存在 bug 无法使用等,我做了一些修复)

git clone [https://github.com/MatheMatrix/skbtracer](https://github.com/MatheMatrix/skbtracer/blob/main/skbtracer.c)

可以简单看下命令介绍

python skbtracer.py --help

可以从 10.0.54.172 发个包文看下收包的路径,skbtracer 可以指定协议、IP 等信息做过滤

python skbtracer.py --proto=icmp -H 10.0.54.172

因为我的这个环境里,地址配在了网桥上,我们可以看下地址配置网卡的收包过程

ip link add link eth0 name eth0.999 type vlan id 999
ip link set dev eth0.999 up 
ip a add 192.168.99.14/24 dev eth0.999

此外,我们可以在发包的虚拟机配一个路由,例如

ip r add 192.168.100.10 via 192.168.99.14

然后往这个假的 192.168.100.10 地址发包,让 192.168.99.14 起到一个路由的作用

skbtracer 能否同时 trace 函数和 netfilter?

此外,可以把 iptable 的 trace 打开:

如果我通过 iptables INPUT 将报文 drop,可以看到 request 最后一步会显示 INPUT drop:

如果我用 tc 来 drop 报文呢?

首先是准备工作

yum --enablerepo=zstack-experimental-mn install kernel-modules-extra
tc qdisc add dev eth0.999 root netem loss 50%

然后发四个报文,其中 seq=2 被丢掉了

查看 trace,发现相比正常 trace 痕迹,这里少了一个 __dev_queue_xmit 把报文从 eth0.999 送到 eth0 的过程,进而也少了 eth0 packet_rcv 的过程,因此可以确认是 eth0.999 把包丢掉了

但如果是在 eth0 上丢包呢?

[root@dev1-4 opensource]# tc qdisc add dev eth0 root netem loss 33%
[root@dev1-4 opensource]#
[root@dev1-4 opensource]# ping 10.0.54.172 -c 4
PING 10.0.54.172 (10.0.54.172) 56(84) bytes of data.
64 bytes from 10.0.54.172: icmp\_seq=1 ttl=64 time=0.346 ms
64 bytes from 10.0.54.172: icmp\_seq=2 ttl=64 time=0.445 ms
64 bytes from 10.0.54.172: icmp\_seq=4 ttl=64 time=0.470 ms
--- 10.0.54.172 ping statistics ---
4 packets transmitted, 3 received, 25% packet loss, time 3052ms
rtt min/avg/max/mdev = 0.346/0.420/0.470/0.056 ms

发送四个报文,seq=3被丢弃了,但是从 trace 上看确实发出去了,没有收到回包,说明我们的 trace 还不够细致,需要改造 skbtracer

如何改造 skbtracer 追踪 tc?

通过对 tc 的原理进行分析可以知道 tc 本质上是在 qdisc 上做文章,通过 perf 可以查到 qdisc 也有相应的 tracepoint,因此我们可以通过在 skbtracer 增加 qdisc 的 tracepoint 来达到目的

[root@dev1-4 skbtracer]# git diff
diff --git a/skbtracer.c b/skbtracer.c
index e62b542..e49c613 100644
--- a/skbtracer.c
+++ b/skbtracer.c
@@ -1,3 +1,5 @@
+#define KBUILD\_MODNAME "skbtracer"
+
#include <bcc/proto.h>
#include <uapi/linux/ip.h>
#include <uapi/linux/ipv6.h>
**@@ -589,6 +591,10 @@ int kprobe\_\_ip\_finish\_output(struct pt\_regs \*ctx, struct net \*net, struct sock \***
**return do\_trace(ctx, skb, \_\_func\_\_+8, NULL);**
**}**
**+TRACEPOINT\_PROBE(qdisc, qdisc\_dequeue) {**
**+ return do\_trace(args, (struct sk\_buff\*)args->skbaddr, \_\_func\_\_, NULL);**
**+}**
**+**
**#endif**
#if \_\_BCC\_iptable
@@ -671,7 +677,7 @@ int kprobe\_\_\_\_kfree\_skb(struct pt\_regs \*ctx, struct sk\_buff \*skb)
event.start\_ns = bpf\_ktime\_get\_ns();
bpf\_strncpy(event.func\_name, \_\_func\_\_+8, FUNCNAME\_MAX\_LEN);
get\_stack(ctx, &event);
- route\_event.perf\_submit(ctx, event, sizeof(\*event));
+ route\_event.perf\_submit(ctx, &event, sizeof(event));
return 0;
}
#endif

为了方便,并没有做什么开关之类的,而是直接加到了 trace 里,让我们来再试一下

[root@dev1-4 opensource]# ping 10.0.54.172 -c 4
PING 10.0.54.172 (10.0.54.172) 56(84) bytes of data.
64 bytes from 10.0.54.172: icmp\_seq=1 ttl=64 time=0.757 ms
64 bytes from 10.0.54.172: icmp\_seq=2 ttl=64 time=0.455 ms
64 bytes from 10.0.54.172: icmp\_seq=3 ttl=64 time=0.388 ms
--- 10.0.54.172 ping statistics ---
4 packets transmitted, 3 received, 25% packet loss, time 3098ms
rtt min/avg/max/mdev = 0.388/0.533/0.757/0.161 ms

这里就很容易看到了,之前的报文都会经过 tracepoint__qdisc__qdisc_dequeue,而最后一个报文却没有到达 tracepoint__qdisc__qdisc_dequeue,也就是在 qdisc dequeue 之前被丢弃了,因此我们的排查方向就可以往 qdisc 的方向排查

对于更新的 kernel 版本,还提供了 qdisc_enqueue 的 tracepoint,就可以更明确的展示出是不是 qdisc 这块搞鬼了

此外,如果你已经逐步的缩小了范围,那么可以通过检查 /proc/kallsyms 里的导出函数或者 perf list 中的 tracepoint 进一步 trace,缩小调查范围

思考题

目前 skbtracer 只做了 ip 层以下大部分和 iptables,如果想要跟踪 ebtables 该怎么改造?

拓展文章

iptables trace target

简介

iptables 有海量的现成的 target extension,参考:https://ipset.netfilter.org/iptables-extensions.man.html 有很多 extension 其实很小众但很有意思,过去的网络(设备)公司很多报文的修改等花活都是在 netfilter 这个框架下加 extension 实现的,当然随着设备公司转向用户态协议栈等方案,netfilter 插件技术也逐渐成文屠龙技了,但还是有很多很实用的可以多探索,例如 trace。

modprobe nf\_log\_ipv4
sysctl -w net.netfilter.nf\_log.2=nf\_log\_ipv4
iptables -t raw -I PREROUTING -p icmp -j TRACE # 根据需求修改过滤条件,这里是针对收包的,maybe 你需要针对入包的

思考题

还能结合什么其他 target extension 来实现 跟踪报文在 iptables 的“流转”过程吗? 

dropwatch

简介

dropwatch 通过跟踪 skb_free 来跟踪丢包,不过功能相对有限,在高版本内核中,dropwatch 完全没有 ebpf 来的灵活好用,但好处是 3.10.X 内核你就可以通过 dropwatch 简单排查,有一个小 tip 是利用大量发报文定位到对应的 dropwatch。例如 iptables 丢包:

iptables -I INPUT -s 10.0.54.172 -j DROP

可以看到是 nf_hook_slow,其实根据函数名就能大致判断是 iptables 丢包,当然也可以进入到对应版本内核代码查看

用 perf 取代 dropwatch 获取更详细的信息

前面有提到 dropwatch 本质上是 trace skb_free 的函数,那么其实无论是 systemtap、ebpf、perf 等各种 trace 工具都可以做到类似的事情,下面我们演示用 perf 获取发生 skb free 的更详细的调用栈

[root@dev1-4 skbtracer]# perf record -g -a -e skb:kfree_skb
Lowering default frequency rate to 2000.
Please consider tweaking /proc/sys/kernel/perf_event_max_sample_rate.
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.404 MB perf.data (453 samples) ]

[root@dev1-4 skbtracer]# perf script
swapper     0 [000] 100201.435009: skb:kfree_skb: skbaddr=0xffff9b364e848b00 protocol=2048 location=0xffffffffb75ae9f7
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb75ae9f7 nf_hook_slow+0xa7 ([kernel.kallsyms])
        ffffffffb75bb29c ip_local_deliver+0xcc ([kernel.kallsyms])
        ffffffffc0b98191 ip_sabotage_in+0x41 ([kernel.kallsyms])
        ffffffffb75ae994 nf_hook_slow+0x44 ([kernel.kallsyms])
        ffffffffb75bb5bc ip_rcv+0x30c ([kernel.kallsyms])
        ffffffffb753ebf7 __netif_receive_skb_core+0xb27 ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffc0b6cf64 br_pass_frame_up+0xc4 ([kernel.kallsyms])
        ffffffffc0b6d154 br_handle_frame_finish+0x164 ([kernel.kallsyms])
        ffffffffc0b9907b br_nf_hook_thresh+0xdb ([kernel.kallsyms])
        ffffffffc0b99a09 br_nf_pre_routing_finish+0x129 ([kernel.kallsyms])
        ffffffffc0b99f91 br_nf_pre_routing+0x341 ([kernel.kallsyms])
        ffffffffb75ae994 nf_hook_slow+0x44 ([kernel.kallsyms])
        ffffffffc0b6d621 br_handle_frame+0x1f1 ([kernel.kallsyms])
        ffffffffb753e767 __netif_receive_skb_core+0x697 ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffb753f68a napi_gro_receive+0xba ([kernel.kallsyms])
        ffffffffc031091e receive_buf+0x17e ([kernel.kallsyms])
        ffffffffc0311413 virtnet_poll+0x153 ([kernel.kallsyms])
        ffffffffb753fb89 net_rx_action+0x149 ([kernel.kallsyms])
        ffffffffb7a000e4 __softirqentry_text_start+0xe4 ([kernel.kallsyms])
        ffffffffb6ebc357 irq_exit+0xf7 ([kernel.kallsyms])
        ffffffffb7801f9f do_IRQ+0x7f ([kernel.kallsyms])
        ffffffffb7800a8f ret_from_intr+0x0 ([kernel.kallsyms])
        ffffffffb76d832e native_safe_halt+0xe ([kernel.kallsyms])
        ffffffffb76d7f9c __cpuidle_text_start+0x1c ([kernel.kallsyms])
        ffffffffb6eebd37 do_idle+0x207 ([kernel.kallsyms])
        ffffffffb6eebf8f cpu_startup_entry+0x6f ([kernel.kallsyms])
        ffffffffb88a01e5 start_kernel+0x51e ([kernel.kallsyms])
        ffffffffb6e000e7 secondary_startup_64+0xb7 ([kernel.kallsyms])

swapper     0 [000] 100201.438796: skb:kfree_skb: skbaddr=0xffff9b364e848b00 protocol=2048 location=0xffffffffb75bacad
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb75bacad ip_rcv_finish+0x20d ([kernel.kallsyms])
        ffffffffc0b98191 ip_sabotage_in+0x41 ([kernel.kallsyms])
        ffffffffb75ae994 nf_hook_slow+0x44 ([kernel.kallsyms])
        ffffffffb75bb5bc ip_rcv+0x30c ([kernel.kallsyms])
        ffffffffb753ebf7 __netif_receive_skb_core+0xb27 ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffc0b6cf64 br_pass_frame_up+0xc4 ([kernel.kallsyms])
        ffffffffc0b6d154 br_handle_frame_finish+0x164 ([kernel.kallsyms])
        ffffffffc0b9907b br_nf_hook_thresh+0xdb ([kernel.kallsyms])
        ffffffffc0b99a09 br_nf_pre_routing_finish+0x129 ([kernel.kallsyms])
        ffffffffc0b99f91 br_nf_pre_routing+0x341 ([kernel.kallsyms])
        ffffffffb75ae994 nf_hook_slow+0x44 ([kernel.kallsyms])
        ffffffffc0b6d621 br_handle_frame+0x1f1 ([kernel.kallsyms])
        ffffffffb753e767 __netif_receive_skb_core+0x697 ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffb753f68a napi_gro_receive+0xba ([kernel.kallsyms])
        ffffffffc031091e receive_buf+0x17e ([kernel.kallsyms])
        ffffffffc0311413 virtnet_poll+0x153 ([kernel.kallsyms])
        ffffffffb753fb89 net_rx_action+0x149 ([kernel.kallsyms])
        ffffffffb7a000e4 __softirqentry_text_start+0xe4 ([kernel.kallsyms])
        ffffffffb6ebc357 irq_exit+0xf7 ([kernel.kallsyms])
        ffffffffb7801f9f do_IRQ+0x7f ([kernel.kallsyms])
        ffffffffb7800a8f ret_from_intr+0x0 ([kernel.kallsyms])
        ffffffffb76d832e native_safe_halt+0xe ([kernel.kallsyms])
        ffffffffb76d7f9c __cpuidle_text_start+0x1c ([kernel.kallsyms])
        ffffffffb6eebd37 do_idle+0x207 ([kernel.kallsyms])
        ffffffffb6eebf8f cpu_startup_entry+0x6f ([kernel.kallsyms])
        ffffffffb88a01e5 start_kernel+0x51e ([kernel.kallsyms])
        ffffffffb6e000e7 secondary_startup_64+0xb7 ([kernel.kallsyms])

swapper     0 [000] 100201.441461: skb:kfree_skb: skbaddr=0xffff9b364e848b00 protocol=2048 location=0xffffffffb75bb348
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb75bb348 ip_rcv+0x98 ([kernel.kallsyms])
        ffffffffb753ebf7 __netif_receive_skb_core+0xb27 ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffc0b6cf64 br_pass_frame_up+0xc4 ([kernel.kallsyms])
        ffffffffc0b6d154 br_handle_frame_finish+0x164 ([kernel.kallsyms])
        ffffffffc0b9907b br_nf_hook_thresh+0xdb ([kernel.kallsyms])
        ffffffffc0b99a09 br_nf_pre_routing_finish+0x129 ([kernel.kallsyms])
        ffffffffc0b99f91 br_nf_pre_routing+0x341 ([kernel.kallsyms])
        ffffffffb75ae994 nf_hook_slow+0x44 ([kernel.kallsyms])
        ffffffffc0b6d621 br_handle_frame+0x1f1 ([kernel.kallsyms])
        ffffffffb753e34b __netif_receive_skb_core+0x27b ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffb753f68a napi_gro_receive+0xba ([kernel.kallsyms])
        ffffffffc031091e receive_buf+0x17e ([kernel.kallsyms])
        ffffffffc0311413 virtnet_poll+0x153 ([kernel.kallsyms])
        ffffffffb753fb89 net_rx_action+0x149 ([kernel.kallsyms])
        ffffffffb7a000e4 __softirqentry_text_start+0xe4 ([kernel.kallsyms])
        ffffffffb6ebc357 irq_exit+0xf7 ([kernel.kallsyms])
        ffffffffb7801f9f do_IRQ+0x7f ([kernel.kallsyms])
        ffffffffb7800a8f ret_from_intr+0x0 ([kernel.kallsyms])
        ffffffffb76d832e native_safe_halt+0xe ([kernel.kallsyms])
        ffffffffb76d7f9c __cpuidle_text_start+0x1c ([kernel.kallsyms])
        ffffffffb6eebd37 do_idle+0x207 ([kernel.kallsyms])
        ffffffffb6eebf8f cpu_startup_entry+0x6f ([kernel.kallsyms])
        ffffffffb88a01e5 start_kernel+0x51e ([kernel.kallsyms])
        ffffffffb6e000e7 secondary_startup_64+0xb7 ([kernel.kallsyms])

swapper     0 [000] 100201.445009: skb:kfree_skb: skbaddr=0xffff9b364e848b00 protocol=2054 location=0xffffffffc0b6d143
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffb7524bd3 kfree_skb+0x73 ([kernel.kallsyms])
        ffffffffc0b6d143 br_handle_frame_finish+0x153 ([kernel.kallsyms])
        ffffffffc0b6d584 br_handle_frame+0x154 ([kernel.kallsyms])
        ffffffffb753e767 __netif_receive_skb_core+0x697 ([kernel.kallsyms])
        ffffffffb753ed8d netif_receive_skb_internal+0x3d ([kernel.kallsyms])
        ffffffffb753f68a napi_gro_receive+0xba ([kernel.kallsyms])
        ffffffffc031091e receive_buf+0x17e ([kernel.kallsyms])
        ffffffffc0311413 virtnet_poll+0x153 ([kernel.kallsyms])
        ffffffffb753fb89 net_rx_action+0x149 ([kernel.kallsyms])
        ffffffffb7a000e4 __softirqentry_text_start+0xe4 ([kernel.kallsyms])
        ffffffffb6ebc357 irq_exit+0xf7 ([kernel.kallsyms])
        ffffffffb7801f9f do_IRQ+0x7f ([kernel.kallsyms])
        ffffffffb7800a8f ret_from_intr+0x0 ([kernel.kallsyms])
        ffffffffb76d832e native_safe_halt+0xe ([kernel.kallsyms])
        ffffffffb76d7f9c __cpuidle_text_start+0x1c ([kernel.kallsyms])
        ffffffffb6eebd37 do_idle+0x207 ([kernel.kallsyms])
        ffffffffb6eebf8f cpu_startup_entry+0x6f ([kernel.kallsyms])
        ffffffffb88a01e5 start_kernel+0x51e ([kernel.kallsyms])
        ffffffffb6e000e7 secondary_startup_64+0xb7 ([kernel.kallsyms])

这里是先用 perf record 记录了 kfree_skb 的事件,同时用 -g 记录了 call graph 方便我们追溯,最后用 perf script 自动处理记录的信息。

第一个报文,有一个 protocol 是 2048,可以在 https://www.iana.org/assignments/ieee-802-numbers/ieee-802-numbers.xhtml 查到就是 IP 报文,常用的,还有 2054 ARP

可以看到这个报文在 nf_hook_slow 之后就被调用了 kfree_skb,无疑就是我们上面分析的那种情况了

后面的几个报文,nf_hook_slow 已经过去了,分别在 ip_rcv_finish、ip_rcv、br_handle_frame_finish 被丢弃,都是些个大函数,而且这些函数往往是通过 goto DROP 的方式丢包,里面丢包的点很多,只能逐个分支分析情况了.

你可能能会想,这里不是给出了偏移量了吗?能否定位到某一行代码?这个留作思考题。

思考题

dropwatch 和 perf 给出来的偏移量能否定位都某一行代码?(提示:分两种情况,如果有对应版本的 debuginfo,和没有对应版本的 debuginfo)

拓展文章

https://blog.huoding.com/2016/12/15/574
https://cloud.tencent.com/developer/article/1631874

SOSP 19′ 文件系统是否适合做分布式文件系统的后端——Ceph 的十年经验总结

过去十年里,Ceph 一直是在本地文件系统的基础上实现。这个是目前大部分分布式文件系统的选择,因为这样可以利用这些实际环境验证过的代码。然而,Ceph 的经验告诉我们这么做也是有代价的——首先,实现一个零开销的事务机制会很困难;其次,本地的元数据性能会极大影响分布式系统;第三支持新的存储硬件会变得很慢。

Ceph 通过一个新存储后端 BlueStore 来解决这些问题,BlueStore 设计为直接在块设备上运行。在其面世的短短两年里,BlueStore 已经被 70% 的生产客户所采用。通过运行在用户态和对 IO 栈的完全控制,BlueStore 实现了高效的元数据空间和数据校验、EC 数据快速覆写、在线压缩、减少了性能的波动而且避免了一系列本地文件系统的隐患(pitfalls)。最后,通过 BlueStore 还让支持一些原本不支持的存储硬件成为可能。

介绍

后端存储采用文件系统的好处:

  1. 无需自行解决数据持久和块分配的问题
  2. 提供熟悉的 POSIX 接口和抽象(文件、目录)
  3. 可以使用lsfind这些常用工具来管理

Ceph 开发 BlueStore 几个主要原因:

  1. 难以在现有文件系统上实现高效的事务操作。
    现有的实现要么有很高的性能损失、要么功能有限、要么接口或实现过于复杂,总之都没有直接集成到文件系统里。
    Ceph 使用用户态的 WAL 来实现,或者一个支持事务的 KV 存储,但性能都不能满意

  2. 本地文件系统的元数据性能极大影响分布式层。具体来说,Ceph 需要快速枚举(enumerate)有几百万条目的目录,但 Btrfs 和 XFS 都支持的不够好;如果拆分目录(directory splitting )来减小一个目录内的文件数量,这个操作在文件系统上有成本极大,会拖垮整个系统的性能

  3. 因为文件系统本身的过于成熟,会导致它对新存储硬件的支持非常慢。例如用来解决 HDD 容量问题的 SMR,用来解决 SSD 的 FTL 层性能损失的 ZSS SSD 都难以支持

BlueStore 的创新之处包括:

  1. 用 KV 数据库保存像 extent bitmap 这类底层的文件系统元数据,用来避免磁盘格式(on-disk format)的变化和减少实现的复杂度;

  2. 通过接口设计优化克隆操作,减小 extent refreence-counting 的成本;

  3. BlueFS,一个用户态文件系统,让 RocksDB 在裸设备上跑得更快

  4. 每 TB 消耗 35MB 内存的空间分配器(space allocator)

除此之外,这篇文章还做了一些实验评估从 FileStore 转到 BlueStore 中一些性能的影响因素,例如日志文件系统的影响、日志双写的影响、拆分目录性能的影响和原地更新的影响(与 COW 相反)。

背景

分布式文件系统,无论是 Lustre、GlusterFS、OrangeFS、BeeGFS、XtreemFS 还是之前的 Ceph,都有几个关键需求:

  1. 高效的事务

  2. 快速的元数据操作

  3. (可能不是通用的)对未来的不向后兼容的存储硬件的支持

因为大部分文件系统按 POSIX 标准实现,因此缺乏事务概念,因此分布式文件系统往往通过 WAL 或者基于文件系统的内部事务机制实现(Lustre)。

无法高效的列举目录内容或者 hanle 海量小文件也是分布式存储使用本地文件系统的一个痛点,为此分布式文件系统就需要通过元数据缓存、哈希、数据库或对本地文件系统打 patch 来解决。

根据硬件供应商的预测,2023 年半数的数据中心将使用 SMR HDD。此外 ZNS SSD 能够通过不提供 FTL 来避免 gc 带来的不可控的延迟。像这种新硬件也是 Ceph 希望支持的。

image.png-381.1kB

上图是 Ceph 的大致架构,考虑到 Ceph 架构的介绍文章很多,这里就赘述了,读者可以搜索任一篇 Ceph 架构的介绍文章。

Ceph 的 ObjectStore 第一个实现是一个叫 EBOFS(Extent and B-Tree-based Object File System ) 的用户态文件系统。2018 年 Btrfs 出现,有事务、去重、校验码、透明压缩都特性,因此 EBOFS 被基于 Btrfs 实现的 FileStore 取代。

FileStore 里,一个对象集合会被映射到目录,数据会被存储到文件。一开始对象的属性是被 POSIX 的 xattrs 保存的,但后来被移到了 LevelDB(xattrs 容量有限)。

Btrfs 被用作生产环境后端很多年,这个过程中 Btrfs 一直有不稳定和数据/元数据的 fragmentation 问题,但因为对象接口的不断演进导致已经不太可能退回到 EBOFS 了,因此 FileStore 被移植到过 XFS、ext4、ZFS,最终因为在 XFS 上良好的 scale 和元数据性能而成为 FileStore 的事实标准。

image.png-116.7kB

虽然基于 XFS 的 FileStore 已经比较稳定了,但是一直受元数据 fragmentation 和无法充分发挥硬件性能的问题困扰。因为缺乏原生的事务,所以用户态的 WAL 实现使用了完整数据的 journal,并受读取-修改-写入这一过程(read-modify-write workloads )的速度限制——这个正是 Ceph WAL 的典型操作过程。此外,XFS 不是一个 COW 文件系统,快照因为需要克隆操作受此影响就会很慢。

NewStore 是 Ceph 尝试通过基于文件系统解决元数据问题的第一次尝试。NewStore 不再使用目录来代表对象及合,而是用 RocksDB 保存元数据。此外 RocksDB 还用来实现 WAL,使得读取-修改-写入过程可以通过合并数据和元数据日志来加速。

这个方案整体来说就是通过文件保存数据、通过在日志文件系统上运行 RocksDB 来保存元数据。但这个方案带来沉重的一致性负担,最终促使了 BlueStore 的开发。

在本地文件系统上构建存储后端的难点

难点一 高效事务

在文件系统上实现事务有三种选择:

  1. Hook 到文件系统内部的事务机制

  2. 在用户态实现 WAL

  3. 使用有事务的 KV 数据库

方案一:Hook 到文件系统内部的事务机制

方案 1 的问题是功能有限,而且很多文件系统没有直接对用户暴露事务。功能有限例如没有回滚机制等。Btrfs 提供了一对系统调用使得内部的事务机制可以对用户暴露。基于 Btrfs 的 FileStore 第一版是依赖于这些系统调用的,但是它没有回滚机制导致很痛苦——具体来说,如果 Ceph OSD 在事务过程中遇到了一个 fatal 事件,例如软件崩溃或者 kill 信号,Btrfs 会提交一个部分(partial)事务,留给存储后端一个不一致状态。

Ceph 团队和 Btrfs 团队都接受的解决方法包括提供一个 entire transaction 系统调用,或者基快照实现回滚,但这两个方案都有很高的成本。最近 Btrfs 废弃掉了事务系统调用,和微软对 NTFS 的决定类似。

方案二:在用户态实现 WAL

方案二是可行的,但是受三个主要问题的影响:

  1. 读取-修改-写入速度

    一个用户态 WAL 实现每个事务需要三步:
    第一步、先对事务序列化,写入到日志;
    第二步、通过 fsync 持久化日志;
    第三步、执行事务内的操作

    这样最终导致整个 WAL 的延迟很高,无法实现高效的 pipeline

  2. 非幂等操作

    FileStore 中对象通过文件表示,对象集合会映射到目录。

    在这种数据模型下,crash 之后重放 WAL 因为一些操作非幂等会导致很有难度。在 WAL 定时 trim 时,总会有一个时间窗口事务日志已经提交到文件系统但事务还没有完成(a window of time when a committed transaction that is still in the WAL has already been applied to the file system)。
    举个例子,考虑一个事务包含三个操作:
    ① 克隆 a 到 b
    ② 更新 a
    ③ 更新 c

    如果在第二步之后发生 crash 了,replay WAL 会破坏 b
    在考虑另一个例子,事务有四个操作:
    ① 更新 b
    ② 将 b 重命名为 c
    ③ 将 a 重命名为 b
    ④ 更新 d

    如果在第三个操作之后发生了 crash,重放 WAL 会破坏 a(也就是现在的 b),然后因为 a 已经不存在而失败。

    基于 Btrfs 的 FileStore 通过对文件系统做周期性快照和对 WAL 做快找时间的标记来解决这一问题。当恢复时,最近的一个快照被恢复,然后 WAL 从相应时间点那一刻开始 replay。

    但因为现在已经使用 XFS 来替代 Btrfs,XFS 缺乏快照带来了两个问题。首先,XFS 上 sync 系统调用是将文件系统状态落盘的唯一选择,但对一个典型的多磁盘构成的节点来说,sync 过于昂贵因为会对所有磁盘生效。这个问题已经被增加 syncfs 调用解决——只同步指定的文件系统。

    第二个问题是在 WAL replay 后,恢复文件系统到指定状态会因为上面说的缺乏幂等性而产生问题。为此 Ceph 又引入了 Guards(序列号 sequence numbers )来避免 replay 非幂等操作。但庞大的问题空间导致在复杂操作下 guards 的正确性也很难验证。Ceph 通过工具产生复杂操作的随机排列,然后加上错误注入来半自动的验证正确性,但最终结果是 FileStore 的代码很脆弱而且难以维护。

  3. 双写。最后一个问题是数据会被写两次,一份到 WAL 一份到文件系统,减半了磁盘的带宽。核心原因是大部分文件系统都只对元数据修改记录到日志,允许在 crash 后丢失数据。然而 FileStore 对文件系统的使用(namespace、state)因为一些 corner case(例如对多文件部分写 partially written files)导致 FileStore 不能像文件系统一样只在日志中记录元数据修改。

    尽管可以说 FileStore 这种对文件系统的使用是有问题的,但这种选择也有技术原因的。如果不这么做就需要实现数据和元数据的内存 cache 以等待 WAL 的任何更新——而内核已经有了 page 和 inode 的缓存。

方案三:使用有事务的 KV 数据库

在 NewStore 方案中,元数据保存在 RocksDB,一个有序 KV 数据库,而对象数据继续在文件系统上以文件形式表示。这样,元数据操作直接在数据库执行;数据的覆盖写被记录到 RocksDB 然后延迟执行。下面介绍 NewStore 如何解决前面说到的用户态 WAL 的三个问题,然后介绍后面因为在一个日志文件系统上运行带来的极高的一致性成本。

首先,因为 KV 数据库的接口允许我们直接读取对象状态而不需要等待上一个事务完成,从而避免了缓慢的“读取-修改-写入”。

其次 replay 非幂等操作的问题通过在准备事务时在读取侧解决。举个例子,克隆 a 到 b,如果对象比较小,那么就复制一份并插入到事务,如果对象比较大,那么就是用 COW 机制,将 a 和 b 指向到同一数据,并把数据标记为只读。

最后,双写的问题也解决了,因为对象的命名空间已经和目录结构解耦,新对象的数据都会先写到文件系统然后自动添加引用到数据库。

尽管上面说了许多好处,但与 journal on journal 类似,日志文件系统与 RocksDB 的组合会带来很高的一致性开销。在 NewStore 上创建对象需要两步:

  1. 写入一个文件并执行fsync
  2. 同步将对象元数据写入到 RocksDB,也会导致一次fsync

理想状态下,每次fsync会导致一次昂贵的FLUSH CACHE 操作到磁盘。但实际上在日志文件系统上每次fsync会带来两次 flush command:一次是写数据,一次是文件系统提交元数据日志。这样导致在 NewStore 上创建对象会产生四次昂贵的flush操作。

下面用一个模拟测试来展示这一开销,测试方法是模拟存储后端创建大量对象,每轮会先写 0.5MB 数据然后插入 500Byte 的元数据到 RocksDB。先模拟 NewStore (在 XFS 上)的实现,然后模拟在裸盘上的实现。

image.png-188.1kB

可以看到裸盘实现比 XFS 实现在 HDD 快 80%,Nvme SSD 上快 70%。

难点二 快速元数据操作

本地文件系统缺乏元数据操作可以说是分布式文件系统艰难的源泉。Ceph FileStore 元数据操作一大挑战就是本地文件系统在对大目录读取(readdir)的性能差和缺乏结果排序。

RADOS 中的对象都会根据名字哈希映射到 PG,然后按照哈希顺序枚举。对于像 scrubbing、recovery 或者 librados list 对象这些操作,这个枚举操作都是必须的。对于具有很长名字的对象,FileStore 通过文件的扩展属性来突破文件系统对名字长度的限制,这样的话就需要一个stat调用才能确定对象的名字。FileStore 采用了一个通用做法来解决这一问题:使用一个大的、散开的(large fan-out)目录层级结构,对象分布在多个目录里,然后组合读取目录的内容并排序。

为了快速排序和减少潜在的stat 操作的开销,目录要保持在比较小的规模(几百个条目这样),这样就要当数量比较大的时候对目录进行拆分。这个在规模较大时会成为一个显著影响性能的操作,有两个原因:

  1. 一次处理百万个 inode 会降低 dentry cache 的命中率,造成大量的磁盘小 IO;
  2. XFS 将子目录放在一个不同的allocation groups里来确保将来有足够的空间把目录条目放置在一起(XFS places subdirectories in different allocation groups to ensure there is space for future directory entries to be located close together),因此随着对象数量的增长,一个目录内容的会不断散开(spread out),然后拆分目录会因为 seek 花费越来越多的时间。

最终的结果是当所有 OSD 一齐执行目录拆分时,会显著的影响性能,这个问题因为已经影响了很多 Ceph 用户数年而被广为人知。

为了展示这一影响,我们配置了一个 16 节点的 Ceph 集群,将 PG 数量设置为推荐值的一半来特意增加目录拆分的压力,RADOS 层队列深度为 128,插入了百万个 4KB 的对象。下图展示了拆分目录的影响,一开始没有什么感觉,第二次拆分导致了性能的急剧下降,并在后面 7 分钟内偶有明显的性能损失,这里展示的是 SSD 集群,对于 HDD 集群,可以观察到 120 分钟的性能损失(没有展示图)。因此这造成在全 HDD 集群里,恢复操作因为seek的高开销而需要比 SSD 高一个数量级的时间成本。

image.png-270.3kB

难点三 对新硬件的支持

以 SMR 为例,如果使用供应商提供的向后兼容的drive-managed SMR接口,会导致性能不可预期(unpredictable performance),如果想达到预期性能,就要使用host-managed SMR提供的不向后兼容的 Zone 接口——这种接口鼓励开发者使用 log-structured、COW 的设计,与现有的成熟文件系统设计完全不同(in-place 覆盖)。

另一个例子就是 OpenCannel SSD,现在主要供应商都开始提供新的 NVMe 标准即 Zoned Namespaces 来定义无 FTL 的 SSD 的接口。去掉 FTL 有很多好处——减少写放大、改善延迟、改善吞吐、减少超分、还能通过减少 DRAM 来降低成本。

这两种新硬件目前都没有成熟的文件系统支持。

其他难点

很多公有云和私有云依赖像 Ceph 这样的分布式存储来提供存储服务,但如果没有对 IO 栈的完整控制,很难定义存储的延迟 SLO。其中一个原因是文件系统为基础的存储会使用系统的 page cache。为了提升用户体验,大部分系统都会设计基于 write-back 的 page cache,这样数据可以 buffer 在内存,当系统的 IO 很少,或者达到了预定的周期时间,就将 page cache 回写到磁盘。对于一个复杂系统,write-back 行为会受一些列复杂策略影响,导致不可预测。

对 Ceph FileStore 来说,尽管有自己周期性的fsync,但它无发现 inode 元数据的 write-back,导致性能不稳定。

再一个难点是基于文件系统的存储后端在实现像 COW 这样的操作。如果后端文件系统是 COW 的,
那么这些操作的实现会很搞笑。但是,他也有一些其他缺点,例如在 Btrfs 上会产生碎片。反过来如果文件系统不支持 COW,那么这些操作就需要成本很高的对对象完整复制,导致快照、EC 的覆盖写代价非常高昂。

BlueStore:一种全新的方法

BlueStore 是一个用来解决上面提到的各种问题的、从头开始写的存储后端,BlueStore 的一些主要设计目标包括:

  1. 快速元数据操作
  2. 对象写入时没有一致性开销
  3. 支持 COW
  4. 没有日志双写问题
  5. 对 HDD 和 SSD 具有优化的 IO pattern

BlueStore 用了两年时间完成了上面的所有目标,并成为了 Ceph 的默认存储后端。这么快的达成(相比通用 POSIX 文件系统需要十年计)有两个关键因素:

  1. BlueStore 只实现了少量的、专用的接口,而不是完整的 POSIX 标准
  2. BlueStore 在用户态实现,可以复用很多完整测试过、高性能的第三方代码

BlueStore 的整体架构如下图所示,BlueStore 运行在裸盘上,BlueSore 的 space allocator 决定新数据的位置,然后数据会通过 Driect IO 异步写入到磁盘。内部元数据和用户定义元数据被保存在运行在 BlueFS 的 RocksDB,BlueFS 是一个很小的为 RocksDB 量身定做的用户态文件系统。space allocator 和 BlueFS 会定时通讯来平衡容量。

image.png-172.2kB

BlueFS 和 RocksDB

BlueStore 通过将原数据保存到 RocksDB 来实现快速的元数据操作;通过下面两点来避免一致性开销
1. 直接写数据到裸盘,从而只有一次 cache flush;
2. 修改 RocksDB 将 WAL 作为 circular buffer 使用,从而达到元数据写入只有一次 cache flush——这个 feature 已经 upstream 到上游。

BlueFS 实现了像openmkdirpwrite这些 RocksDB 所需的基本系统调用。BlueFS 的磁盘布局如下图。

image.png-210.1kB

BlueFS 为每一个文件维护一个 inode,其中包含为这个文件分配的 extent 信息。superblock 保存在固定位置,包含 journal 的 inode。journal 有文件系统元数据的唯一副本,mount 时加载到内存。每当有元数据操作例如创建目录、文件和分配 extent 时,journal 和内存里的元数据会被更新。journal 不保存在固定位置,它的 extent 会与文件的 extent 有交错。每当达到一个阈值时,journal 会被压缩并写到新的位置,这个新的位置被记录到 superblock 里。这样设计之所以可行是因为得益于大文件和周期压缩会限制任一时刻 volume 元数据的数量。

关于元数据组织,BlueStore 在 RocksDB 中使用了多个命名空间,每个命名空间用来保存不同类型的元数据。举例来说对象信息都保存在命名空间 O 中(也就是说 RocksDB 中 O 开头的 key 都表示对象的元数据),块分配元数据保存在命名空间 B,集合元数据(collection metadata)保存在命名空间 C。每个集合(collection)映射到一个 PG,并代表 pool namespace 得一个 shard。collection 的名字包含 pool 的标识和collection 里对象名字的统一 prefix。

举个例子,一个 kv:C12.e4-6标识 pool 12 的一个集合,这个集合里的对象的哈希以 e4 的 6 个最高有效位开头(hash values starting with the 6 significant bits of e4)。例如对象 012.e532 就是这个集合的成员(前六位是111001),而 012.e832 就不是(前六位是111010)。这种元数据组织方式允许只通过修改有效位数的数量(the number of significant bits)把数百万的对象分割成多个集合。这样比如有加入的 OSD 增加了总容量或者现有 OSD 因为失效从集群移除时,FileStore 在拆分 collection 时就需要昂贵的目录拆分,而 BlueStore 就简单很多。

数据路径和空间分配

BlueStore 是一个 COW 的后端。对于大于最小分配大小的写请求(对于 HDD 是 64KB、SSD 是 16KB),数据会被分配到一个新分配的 extent。当数据持久化之后,对应元数据就会插入到 RocksDB。这允许 BlueStore 提供高效的克隆操作。克隆操作只需要增加所需要的 extent 的引用计数,然后将新写入指向到新的 extent。这允许 BlueStore 对大于最小分配大小的这部分写、部分写请求避免日志双写

对于小于最小分配大小的写请求,数据和元数据都都会被先保存到 RocksDB 然后将来随事务异步写入到磁盘。这个延迟写的机制有两个目的:
1. 合并提交小 IO 来提高效率——写一个新数据需要两次 IO 而插入到 RocksDB 只需要一次
2. 根据设备类型优化 IO,通过在 HDD 上异步写 64KB 以下的数据来避免在读操作过程中 seek(avoid seeks during reads),再 SSD 让原地覆盖写(in-place overwrite)仅发生在 16KB 以内的 IO。

关于空间分配,BlueStore 使用两个机制来分配空间:FreeList manager 和 Allocator。FreeList 作为一个磁盘当前使用的持久化记录。就像 BlueStore 的所有元数据一样,它首先被保存到 RocksDB。FreeList manager 的第一版实现被设计为通过 offset 和 length 的键值对表示已使用的 region。这个设计的缺陷在于必须对事务进行序列化——为了避免 free list 不一致,需要先删除旧的 key,然后插入新的 key。第二版设计为基于 bitmap。分配和回收操作使用了 RocksDB 的 merge 操作符来反转受影响的 block 所对应的 bit,从而消除了排序这一要求。RocksDB 中的 merge 操作符执行延迟的、原子的读取-修改-写入操作(deferred atomic read-modify-write operation),与原方法相比不会改变语义也不需要查询的开销。

Allocator 负责为新数据分配空间。他保存了一份 free list 的内存拷贝,并且会在分配后通知 FreeList Manager。Allocator 的第一版实现是基于 extent 的,将可用 extent 划分到 2
的 n 次幂的容器中(power-of-two-sized bins)。随着磁盘使用量的增加,这个设计容易产生碎片。第二个设计使用索引结构,这个结构在一个“一位表示一个 block”(single-bit-per-block)的描述之上来跟踪块的所有区域(track whole regions of blocks)。通过查询高层和低层索引,可以有效的找到大的和小的 extent。这种实现对每 PB 使用固定的 35MB 内存。

关于 Cache,因为 BlueStore 实现在用户空间且通过 Direct IO 访问磁盘,所以它不能够利用到操作系统的 page cahce。所以 BlueStore 在用户层使用 scan resistant 2Q 算法实现了自身的 write-through 缓存。缓存通过 shard 来并发。它使用了和 Ceph OSD 一样的 shard 模式,对到多个集合的请求通过不同 core 来 shard。这样避免了 false sharing,这样同一个 CPU 的上下文始终访问它对应的 2Q 数据结构。

得益于 BlueStore 所实现的功能

本节会介绍由于具备了对 IO 栈的完整控制后,BlueStore 所能实现的过去无法实现的功能。

高空间利用率的 checksum

Ceph 会每天 scrub 元数据、每周 scrub 数据,但即使有 scrub 机制,数据如果在不同副本间不一致也很难确定哪个副本是受损坏的那个。因此 checksum 对分布式存储很重要,特别是对于 PB 级的数据,几乎是必然会发生位翻转这些错误。

绝大多数本地文件系统是不支持 checksum 的。一些支持的,比如 Btrfs,会对每 4KB 计算校验和以方便覆盖写 4KB block。那么对于 10TB 的数据,为每 4KB 数据存储 32 位的 checksum 需要最终占用 10GB 的空间,这将导致难以将 checksum 缓存到内存来做快速验证。

另一方面,大部分存在分布式文件系统的数据是只读的,可以以更大粒度来计算 checksum。BlueSTore 对每次写请求计算一个 check,并且在每次读取时计算。BlueStore 支持多种 checksum 算法,其中 crc32c 是默认选项——因为它在 x86 和 arm 上都有良好的优化,而且也足以探测随机的位错误。由于对 IO 栈的完整控制,BlueStore 可以根据 IO 迹象(hint)来选择 checksum 的 block size。举例来说,如果根据 IO 推测写请求来自 S3 兼容的 RGW 服务,那么对象是只读的,checksum 可以以 128KB 为粒度计算。如果 IO 是需要压缩的对象,那么 checksum 在压缩后计算,显著的减小了 checksum 的大小。

EC 数据的覆盖写

Ceph 从 2014 年 FileStore 就支持了 EC pool,然而这个支持仅限追加写和删除操作,因为覆盖写在这个设计下太慢了,几乎无法使用。结果是,EC 池尽在 RGW 场景有用,对于 RBD 和 CephFS 是无法使用。

为了避免 Raid write hole 问题,多步骤的数据更新时,如果发生 crash 会导致系统不一致,Ceph 在 EC 池的覆盖写使用两阶段提交。首先,所有存储这个对象的一个 chunk 的 OSD 都会复制这个 trunk 来为失败回退做准备。当所有 OSD 收到新的数据内容要覆盖写 trunk 时,旧的 trunk 副本就会被丢弃掉。在 XFS 为基础的 FileStore 下,第一步是非常昂贵的,因为需要一次物理复制。而对 BlueStore 来说得益于 COW 就避免了完整的物理复制。

透明压缩

透明压缩对横向扩展分布式文件系统是很关键的,因为 3 副本会增加存储的成本,BlueStore 实现了透明压缩,在写数据时会在其落盘前自动压缩数据。

要充分发挥压缩衣的优势,需要被压缩的 chunk 至少是 128KB 以上,而且是对整个对象压缩。对于压缩对象的覆盖写,BlueStore 会先把放在单独为止,然后更新元数据指向到它。当压缩对象碎片化比较严重时,BlueStore 会执行 compact 操作。实践中,BlueStore 会使用简单的启发式策略和 hint 只压缩那些不大可能会被覆盖写的对象。

新接口

因为不受本地文件系统的 block-based 设计约束,BlueStore 在探索新的接口和数据分布上有了更高的自由度。最近,RocksDB 和 BlueFS 已经被移植到 host-managed SMR 上,而在这类设备上存储数据也已经在作为下一个努力方向。此外 Ceph 社区还在探索一些新的后端,尝试将持久化内存和新的 NVMe 设备组合,例如 ZSN SSD 和 KV SSD。

评估

以下评估都是在 16 节点通过 Cisco 3264-Q 交换机连接起来的 Ceph 集群,每个节点有一个 16 核 E5-2698Bv3 2Ghz 处理器,64GB 内存,400GB P3600 NVMe SSD,4TB 7200 RPM Segate HDD,Mellanox 40Gb 网卡。系统是 Ubuntu18.04,内核为 4.15,使用 Luminous 版本(v12.2.11),使用 Ceph 默认配置。

直接对 RADOS 测试

写吞吐数据:
image.png-246.9kB

写延迟数据:
image.png-325.8kB

在读性能上 BlueStore 没有表现出优势,因为 FileStore 实现了 Read ahead,而 BlueStore 可以没有实现。

前面提到的拆分对 FileStore 的影响与 BlueStore 在相同条件下做对比,BlueStore 前面的性能下降是因为 RocksDB 的 compact 影响没有到达一个稳定状态:

image.png-183.7kB

RBD块设备测试

测试前会 drop 系统 cache 和重启 BlueStore OSD 来避免 cache 的影响。
image.png-267.2kB

可以看到首先 BlueStore 的吞吐更好——这个很大一部分来源于规避了双写,其次方差更小——因为 BlueStore 直接将数据写入磁盘,而 FileStore 的系统 write back 触发和 Ceph 前台 WAL 相冲突引起较长的 latency。

对于小于 64KB 的写请求,BlueStore 要比 FileStore 好 20%,因为 BlueStore 会将数据写入到 RocksDB 就返回。(MatheMatrix:无图)

在读性能上 BlueStore 没有表现出优势,和前面一样,因为 FileStore 实现了 Read ahead,而 BlueStore 可以没有实现。

EC数据的覆盖写

image.png-147kB

在裸设备上实现高性能存储后端所面临的挑战

cache 大小和 writeback

文件系统可以直接利用内核的 Page Cache,但基于裸设备的存储就得自己从头实现类似的机制。具体参考4.2 节。

高效的 KV 存储

Ceph 团队的经验是将所有元数据放到有序的像 RocksDB 这样的 KV存储可以有效提高原数据性能。然而,在整合过程中也遇到一些问题:

  1. RocksDB 的 compact 写放大和导致无法充分使用 NVMe 的性能;
  2. 因为把 RocksDB 视为一个黑盒,因此序列化和反序列化数据花了更多的 CPU 时间;
  3. RocksDB 有自己的线程模型,限制了自定义 sharding。

高效的 CPU 和内存使用

现代编译器会对内存里的数据做 align 和 pad 来提高 CPU 读取的性能,但对复杂的数据结构,这种做法会浪费大量内存。对大部分应用这件事都不需要太过关心,因为他们的内存里的数据结构生命周期都很短。但对于绕过 page cahce 的存储后端,长时间这么运行会几乎占掉机器所有的内存,Ceph 团队花了很久时间,主要通过 delta and variable-integer encoding 等技巧 pack 保存到 RocksDB 的元数据来减少元数据大小和降低 compact 的开销。

另一个问题是在高端 NVMe 设备上,BlueStore 性能受 CPU 所限制,因此对于下一代后端,Ceph 社区在探索减少对 CPU 的使用,例如降低数据的 serialization-deserialization、结合使用 SeaStar 和 shared-nothing 模型——避免 lock 造成的上下文切换。

相关工作

关于事务,in-kernel 实现可以参考 Btrfs、NTFS、Valor、TxFS 等,基于用户文件系统的数据库可以参考 Amino(基于 Berkerly DB)、Inversion(基于 Postgres),操作系统级的实现可以参考 QuickSilver、TxOS。

关于元数据优化,相关工作有很多,例如 BetrFS、DualFS、hFS、FFS、TableFS、DeltaFS。后两者将元数据保存在 LevelDB 之上。

总结

大部分分布式文件系统都是基于本地文件系统实现,然而却导致了极大地复杂性,这种要基于本地文件系统设计的信念源于从头实现成本巨大,需要十年以上才能成熟。而 Ceph 的经验告诉我们这种想法并不准确,而且带来了——性能优势、支持新设备、通过对完整 IO 栈的控制实现过去难以实现的 feature。

Copy hostname from chrome address bar rather than whole url

I often copy hostname from chrome address bar to shell, but it always with some prefix like “http://” .

Search “chrome copy without http” in Google and you will 208,000,000 results, there is a bug reported in tracker but status is “Won’t fix”.

Luckily some body developed this extension:

https://chrome.google.com/webstore/detail/hostcopy/ebnjnkfienhcidbgmifkjkkidheihcpj

there are some others has same function, you can try and choose your favorite.

pyroute2 is not so fast

接上篇。

由于发现了 subprocess.popen() 的线程泄露问题,我们对这个调用加了线程锁,最简单的绕过这个问题,与此同时我将一些频繁调用的地方从 bash 调用改成 linux 系统调用(比如直接使用 read,而避免 bash 调用 cat),在很多场景有了不错的性能提升。

于是我怀疑 kvmagent 的一些性能问题和我们的 bash 的有些滥用有关系,我们知道 pyroute2 是直接调用 netlink 的,因此我做了这个测试:

可以看到 read 最快,bash+cat 次之,pyroute 居然最慢。

考虑到 pyroute 可能初始化的 workload 更大,我们可以试试先初始化好 import 和必要的初始化工作:

so, pyroute2 is not so fast.

devconf 19′: virtio 硬件加速

前言

devconf 也是我比较关注的一个 summit,devconf 的内容当然比较偏实践,但有一些东西还是比较前沿的。

很多人会认为 virtio 是一套实现(virtio-net, virtio-blk 等等),但实际上 virtio 是一套标准(或者说抽象层),因为 virtio 通过半虚拟化的方式来加速虚拟化的性能,那么就需要 hypervisor 和 guest 的协作来达到目的,其中 hypervisor 端我们称为 backend driver,guest 端称为 frontend driver。

virtio 的具体介绍在 developer works 有一篇很好的文章,如果对 virtio 不了解的话可以参考这篇: https://www.ibm.com/developerworks/cn/linux/l-virtio/index.html,进一步的,还可以阅读 Rusty 写的原论文:https://www.ozlabs.org/~rusty/virtio-spec/virtio-paper.pdf

这里简单介绍一下 virtio 的基本架构,就是下面这张图:

image.png-54kB

可以看到 IO 的核心就是 virtqueue,virtqueue 定义了 add_bufget_bufkick 等几个关键 IO 接口。

virtio 刚提出时其实是很先进的,因为通过共享内存替代了完整的 trap/模拟过程,大大提升了性能,但是随着底层 IO 设备性能的越来越强,大家对 virtio 也逐渐提出了更高的要求,例如通过 vhost 来加速等等,但是即使像 vhost、vhost-user 这些技术也解决不了对 hypervisor 资源(特别是 CPU)的占用问题,因此需要更加适合高性能 IO 设备的技术了。

一种思路是设备透传,作者在这里简单讲了下设备透传的缺点:

image.png-117.3kB

主要是一来热迁移很难做,当然并不是说完全不能,今年的 KVM Forum 上就有 topic 讲 GPU 透传怎么做热迁移,Netdev 也有讲 SRIOV 网卡怎么做透传,但有几个问题:

  1. 热迁移实现和透传设备类型强相关,例如上面 GPU 的热迁移和 SRIOV 网卡的设计完全不同
  2. 需要很多的代码改动,不能复用现有的 virtio 设备热迁移框架
  3. 更加灵活可控

为此 oasis 现在发布了 virtio 1.1 spec,在 devconf 时还是 draft 阶段,现在已经正式发布了:https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.html。virtio 解决的核心问题就是性能,这个不仅包括软件实现的性能,也包括硬件实现的性能(和实现的难度)。

设计

Packed virtqueue

首先 virtio 1.1 最重要的改变之一就是 virtqueue 的改变,由 split virtqueue 转为 packed virtqueue。这里我要先讲一下 split virtqueue 是什么,以及遇到了什么问题再讲 packed virtqueue。

image.png-117.1kB

本节的图来自去年的 DPDK Summit,Jason Wang(Redhat)和 Tiwei Bie(Intel)在去年 DPDK Summit 对 Virtio 1.1 做了很好的介绍,推荐阅读,原 Slide 在:https://www.dpdk.org/wp-content/uploads/sites/35/2018/09/virtio-1.1_v4.pdf

Split virtqueue 顾名思义,queue 会有多个 ring,分别是 available ring、descriptor ring 和 used ring,每条记录都通过
next 指针来标识下一条记录,这样就会有下图所示的几个问题,而且对于硬件实现来说,这些跳转会带来开销比较高的 pci transaction,不利于性能提高。

image.png-182.8kB

下面的是 packed virtqueue,packed queue 把原本分散在三个 ring 的元数据组合在了一起,这样元数据读取的软件实现可以减少 cache miss,硬件实现可以只用一个 PCI transaction 解决。

image.png-141.7kB

其他

另外就是一些新的特性,这些特性需要在设备 negotiate 时决定是否开启,比如:

  1. in-order completion。以往 ring 的完成是可以乱序完成的,这样 driver 实现就需要做的更复杂,也不利于优化(比如不好做批量动作)
  2. 支持内存访问有限制的设备(比如设备的内存访问要经过 IOMMU)
  3. 支持开启关闭特定 ring buffer 的 notification,硬件实现可以减少 PCI transaction
  4. notification 增加更多的信息,这样硬件实现上可以并行做更多事情,而且减少了在 PCI bus 上来回获取信息需要的时间。

目前的状态

硬件实现是需要看供应商的,所以这里除了 paper work 之外我们还可以说下目前软件实现的状态。

Packed virtqueue

packed virtqueue spec 其实已经定下来了,也发布在了 1.1 spec 中,对应实现需要 front 和 backed 都改,所以当前状态如下:

image.png-58kB

dpdk 这边进展是比较快的,通过 dpdk 理应已经可以测试 packed queue 带来的效果。

vDPA

image.png-227kB

即使有了上述规范,实现一个硬件 backend 的 virtio 设备也是比较繁琐的,因此 Intel 提出了 vDPA 这个框架,可以理解为如果你真的打算用硬件来做 virtio backend,vDPA 帮你把通用的一些工作已经做好了,例如硬件设备抽象、IO 路径等等,Intel 目前给出了 vDPA 下两个驱动,一个是 IFCVF,用来支持 Intel FPGA 100G,不过这个 FPGA 开发板可是很贵,后来 Intel 又提供了 vDPA Sample,这样你可以从中学习 vDPA 的工作方式。

vhost-mdev

vDPA 其实挺好的,但 Intel 后来又提出了 vhost-mdev,上面 vDPA 的架构图你也能看到有个 vhost/mdev,这是为什么呢?在我看来主要是因为 vDPA 原本设计比较面向网络,我们要先看下 vDPA 的原本设计细节架构(摘自 kvm forum 2018):

image.png-422.7kB

可以看到 vDPA 做了数据面,但是控制的部分其实是 virtio-net(vhost-user)实现的,这样在刚开始做 vDPA 来说很方便,因为减少了很多工作,但如果面向别的类型设备准备实现 virtio 硬件加速,比如存储啊、一些辅助加速设备啊什么的就会发现 vDPA 帮我们减少了数据面的代码量,但控制面还是需要很多工作。就像 vDPA 在 DPDK 里做 vdpa driver 这样解决 vhost 到 vDPA 这个过程。

有没有什么通用 IO driver 呢,其实是有的,vfio。

所以我们可以在前端使用 vfio,在后端做一个 mdev 对接下面具体的 virtio 加速设备,厂商可以自定义 MMIO、PCI 空间等等这些控制面细节。

image.png-406.8kB

vhost-mdev 目前还处于比较早期的阶段。

Intel Cascade Glacier

我对 FPGA 很多内容也不了解,只知道这个是 Intel 去年正式发布的 FPGA 智能网卡,可以实现 virtio 加速,OVS 卸载,从 Intel 在去年 OVS Conf 的介绍看,Intel 为之提供了一套 SDK 和软件栈。

image.png-342.5kB

此外还支持 P4:

WX20190508-003724@2x.png-742kB

总结

  • 越来越多供应商开始对 virtio 硬件加速感兴趣
  • 目前已经有至少一个硬件 ready
  • 有通用的软件框架支持各种硬件
  • Virtio 1.1 为硬件实现做了很多优化和改进

Eurosys 19′ Notes:Ursa: Hybrid Block Storage for Cloud-Scale Virtual Disks

Ursa 是美团云 16 就发布过的面向 IaaS 云主机的块存储系统,目前 Ursa 主要有几篇公开文章讨论其架构:

最早:https://tech.meituan.com/2016/03/11/block-store.html 介绍了 motivation、和其他块存储的比较
17 年在知乎专栏发表了基于混合存储的效率优化,和这次 Eurosys 19′ 内容相关:https://zhuanlan.zhihu.com/p/27695512
17 年还有一篇 USENIX 17′ 的文章:https://tech.meituan.com/2017/05/19/speculative-partial-writes-erasure-coded-systems.html 介绍了对 EC 的优化

下面介绍这篇文章,原文地址:https://www.cs.jhu.edu/~huang/paper/ursa-eurosys19.pdf or https://dl.acm.org/citation.cfm?id=3303967

简介

通过追踪块存储的 IO pattern 可以发现其 IO 的 locality 很差,因此相对于使用 SSD 作为 cache layer,Ursa 选择了底层直接使用 SSD-HDD 混布方案,将主副本放在 SSD 上,备副本放在 HDD 上,通过 Journal 来弥补 SSD 和 HDD 之间的性能差距。实验显示之中模式在大部分情况下可以达到与全 SSD 相同的性能,与全 SSD 的 Ceph 和 Sheepdog 相比效果也很好,而且有更高的 CPU 效率。

最近几年有一些提升虚拟磁盘吞吐的研究,例如 NSDI 14′ 发表的 https://www.usenix.org/system/files/conference/nsdi14/nsdi14-paper-mickens-james.pdf ,但低成本的提高虚拟磁盘效率还是很难。通过生产环境的实验以及过去的研究(https://www.usenix.org/legacy/event/fast08/tech/full_papers/narayanan/narayanan.pdf )可以发现块存储 IO 有两个典型特征:

  1. 大部分情况都是小 IO 为主,偶尔有顺序大 IO
  2. 读和写的 locality 都很差

因为小 IO 为主,因此 SSD 在构建块存储上一定优于 HDD。但是 SSD 的价格和能耗使得全 SSD 成本太高。因此考虑到第二个特征——locality 弱,使用 SSD 作为 cache layer 效果也并不会好,根据之前的研究,考虑到高端 SSD 在延迟和 IOPS 上高 HDD 三个数量级,因此哪怕 1% 的 cache miss 也会导致平均 IO 性能降低到预期的 1/10。此外,SSD cache 对长尾延迟帮助很小,而这个是云供应商 SLA 的一个重点。最后,额外的 Cache 层还会造成块存储层的一致性问题,例如 Facebook 在 2010 年就发生过这样的悲剧。

根据上面的介绍,Ursa 使用了 SSD、HDD 混布方案,SSD 作为主副本,HDD 作为备副本,为了弥补之间的巨大性能差距,通过 journal 来将 HDD 上的随机写转化为顺序的日志追加写,再把日志异步 replay、merge 回磁盘。为了提高效率,偶尔的大的顺序写还是直接发到 HDD,跳过 Journal。这篇 Paper 将介绍几个方面:

  1. SSD-HDD 混布方案的设计,同时为了解决 Journal 和副本的结合带来的复杂性,设计了 efficient LSMT(log-structured merge-tree) 来达到快速的 invalidate 无效 journal 和在故障恢复时快速读取 journal;
  2. Ursa 中的多个级别的并行,包括磁盘并行 IO、磁盘间的条带化和网络 pipeline,通过这些方法来提高吞吐性能;
  3. Ursa 的满足强一致性(线性一致性)的复制协议,rich-featured client 和在线升级的高效机制。

实验显示 Ursa 在混布模式下提供了接近全 SSD 的性能,与全 SSD 模式的 Ceph 和 Sheepdog 相比还实现了更高的 CPU 效率。实际环境验证显示能在更少的 SSD 数量下提供全 SSD 部署的 AWS、腾讯云的块存储相媲美甚至更好的性能。

动机

首先我们研究了微软在之前文章发布的数据以及自己收集的数据,显示可以看到 70% 的 IO 大小都小于 8KB,几乎所有 IO 都不大于 64KB,这说明块存储中小 IO 是主要组成。

image.png-56.8kB

因为 HDD 在随机小 IO 上的低性能,高性能存储往往使用 HDD 来构建,传统的 SATA SSD 比 HDD 在 IOPS 和延迟上要好两个数量级,PCIe SSD 的话更好。而且 SSD 比 HDD 有更低的故障率和相似的寿命。

SSD 的主要缺点就是价格,特别是基于副本的存储。一种办法是使用 SSD、RAM 做 Cache,但是 Cache 效果并不好。

image.png-37.9kB

上图显示了在读方面很低的 cache 命中率,因为这里很多数据都是只读一次。再考虑到 SSD 和 HDD 随机 IO 巨大的性能区别,稍低一点 cache 命中率就会很大影响整体性能,而且这种不稳定的 cache 命中会影响云服务的 SLA。

因此这里介绍的 SSD-HDD 混合块存储直接将主副本保存在 SSD 上,然后再复制到 HDD 上,所有客户端读一般来源于 SSD,所以没有 cache miss 的问题,不过问题是如果每次写都要同步复制到 HDD 的话,HDD 的性能会直接拖累这种简单的 SSD-HDD 混布设计,这样就没有意义做混布了,所以这里通过 journal 来弥补之间的差距,在异步 replay。尽管长期看起来看平均写性能还是受限于 journal 在 HDD 上的 replay,但实践中客户端感受到的随机小 IO 要比 journal 的顺序写性能好。

设计

架构概览

image.png-81.4kB

  1. Chunk Server。每个 data chunk 有一个主副本和多个备副本保存在不同机器,每个机器插多个 SSD 和 HDD,既保存 primary 也保存 backup。
  2. Client。VMM 通过 client 使用 NBD 接触块存储。client 查询和管理元数据(例如云盘的创建、打开、删除)时与 master 交互。这种交互是 stateless 的。
  3. Master。Ursa 中有一个类似 GFS 中的 master 来简化管理操作。master 不参与正常的 IO 路径来避免成为瓶颈。master 提供 coordination,包括云盘创建、打开、删除,元数据查询,状态监控,故障恢复等等。

SSD-HDD 混合存储架构

对于一个读请求,primary server 直接读取来自 SSD 的数据,而写请求则先写到 SSD,然后复制到 backup(backup 会在写完 journal 返回),最后返回到客户端。

journal 既可以保存在 SSD 也可以保存在 HDD 上,Ursa 选在保存在 SSD 上因为有更好的并发 IO 性能,此外使用一个 in-memory index 来保存 chunk offset 到 journal offset 的对应。

长期来看,journal 这种方法也会受限于 HDD 的随机写性能,但是:

  1. 写操作里有很大比例是在 overwrite,而 overwrite 到 journal 可以合并
  2. 通过 journal 重放时做合并和调度可以减少 HDD 磁头移动

Journal 都保存在 backup 本地,因为本地 journal replay 要比跨机器的 replay 简单很多。根据实践经验,Ursa 使用 SSD 1/10 的容量存放 journal。

按需 journal 扩展

当一个 SSD 耗尽 journal 的空间后,Ursa 可以动态扩展 journal 到另一个负载最低的 SSD(其 journal 空间没有用完)。如果所有 SSD 的 journal 空间都用完了,那就使用 HDD 的空间存放 journal。理论上讲 HDD journal 可以按需任意大,但是和日志文件系统不同(例如 https://www.researchgate.net/publication/221235948_DualFS_A_new_journaling_file_system_without_meta-data_duplication),journal replay 是必须的,不仅是空间效率的问题,还有快速恢复的原因。

HDD journal 设计与 SSD 几乎一样,除了 HDD journal replay 只会发生在 HDD 空闲时。在 Ursa 线上部署的两年时间里,HDD journal 从来没有用到过。这是因为条带将数据分布到很多机器上上,这样很难出现某一台机器集中产生大量写入。而且如果一个客户端产生大量的 IO 会先被 master 限速而不是把 SSD journal 耗尽。

Journal bypassing

大于 64KB 的 IO 会跳过 journal 直接写入 HDD。

Client direct replication

为了降低极小 IO 的延迟,考虑到很小的 IO 只占用极小带宽,小于 8KB 的 IO 会在 client 直接发到 primary 和 backup,不通过 primary 来复制。这个是这样算出来——每个机器两个万兆网口,希望最多只用一半的带宽,然后提供给客户端最高 40K 的 IOPS,考虑到三副本,那就是 20Gb/2/40K/3 ~= 10.4KB,所以设置为 8KB。

Journal index

因为上面介绍的 bypass,数据复制和恢复的复杂度会上升。为此 Ursa 设计了 per-chunk in-memory index 结构,map 了每个 chunk ofsset 对应 journal offset,这样可以快速 invalidate journal 和快速恢复。

journal index 一般通过 LSMT 设计,然后原本的 LSMT 无法满足 Ursa 在 invalidate 和 recovery 时的要求:

  1. LSMT 的 index 的 key space 是连续整数
  2. LSMT 的 index 查询和更新对 journal 读写有着严重影响,所以会直接决定 Ursa 的 IO 性能

因此 Ursa 对 LSMT 做了一下优化,显著提高了 range query 和 range insertion 的性能。

Composite keys

如果 key 对应的 journal offset 是连续的,那就合并为一个 composite key {offset, length}。composite key 之间定义了 LESS 关系:x LESS y iff x's offset+length <= y's offset,这样 composite key 形成了一个有序关系。

Index operations

因为上面定义的有序关系,因此快速的查询和更新就可以很快。

image.png-32.5kB

Index storage

KV({offset, length} -> j_offset)保存为一个 8 字节的结构体。Ursa 在 KV 中保存的是一个两级结构,第一级是红黑树,高插入效率,低存储效率,第二级是有序数组,插入效率低但存储效率和查询效率高。

当新增一个 KV 的时候,Ursa 首先快速插入到红黑树,然后由后台的低优先级工作线程异步合并到数组。因此数组中保存的 KV 可能是过期的(红黑树中的数据还没有同步到数组),所以查询查询时会先查询红黑树,然后在对 missed range 去数组查询。这样一级红黑树实际上是二级数组的一层小容量 cache。对于数组,8GB 的内存可以保存十亿条记录,相当于在 16TB journal 上全部以 16KB 的 IO 写入所需要的记录数量(16TB/16KB)。

多个层面的并行

磁盘层面

为了充分利用 SSD 的并行 IO 能力,每个 SSD 会运行多个 chunk server 进程,每个进程使用一个 libaio 线程。进程内 Ursa 会把 libaio 事件转换为协程,通过一个同步调用接口来隐藏 libaio,相反的,HDD 上只运行一个单线程进程,不使用 libaio。

跨磁盘层面

跨磁盘层面有三种并行机制,包括:

  1. 条带化
  2. 乱序执行
  3. 乱序完成

首先,Ursa 将一个虚拟机磁盘分割成多个固定大小的 chunk,然后两个或多个 chunk 组成 strip group,这样大的读写可以并行在多个 chunk 上。service manager 来保证数据 placement policy,即一个 strip group 的所有 chunk 不在同一磁盘或物理机上。

其次只要 IO 落在在不同的 chunk 上,Ursa 就允许它们乱序执行。

最后 Ursa 支持乱序返回。比如对某个 chunk IO 请求顺序是先有请求 r1,然后是请求 r2,那么返回时可以相反。

网络层面

网络拥塞和操作系统调度可能造成端到端的延迟。Ursa 的做法是使用对每个链接使用 pipeline 来处理 IO 请求,这样网络延迟对总体的 IOPS 和吞吐的影响就小很多。不过这样显然会有对上层的崩溃一致性的问题(因为完成顺序和发起顺序不一致),这个应当由上层来保证,例如 Linux Ext4、XFS 都有 journal 机制,OptFS 有 osync 和 dsync 来保证最终一致和持久化时保持一致(MatheMatrix:我觉得可以参考 OSDI 14′ 的这篇文章 https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-pillai.pdf,以及我们实际遇到过的,一些文件系统并不一定达到了完整的崩溃一致性)。

一致性

概览

Ursa 通过 chunk server 间的 totally ordering write 来提供对每个 chunk 的线性一致性。简单来说,Ursa 的复制协议和 Paxos、Raft 这样的 RSM(Replicated State Machine)是同源的。每个 chunk 有版本号,chunk server 和 master 共同维护持久化的视图号(view number),view number 会在故障恢复后分配新 chunk server 时更新。

当客户端打开虚拟磁盘时,客户端会从 master 获得每个 chunk 的视图号和位置,然后向所有 replica 查询它上面的视图号。一旦视图号得到确认,client 会选择一个 replica 作为主副本(一般是 SSD 的那个),然后对主副本做读写请求,如下图。

image.png-82.5kB

如果 client 检测到某个 replica 失效,就会汇报到 master 重新分配一个 replica,更新 chunk 的位置,增长视图号。

尽管 Ursa 的复制协议 follow 了经典的设计原则,但是还是有两方面明显不同。其一是 Ursa 保证任意时刻只有一个 client 可以 access 虚拟磁盘(MatheMatrix:如果客户需要共享云盘?后面有解答);其二是混合错误模型来区别对待副本和网络失效。

Single client

Ursa 通过租约和锁协议来保证任何时候最多只有一个虚拟机可以某一个云盘读写数据。这样大大简化了强一致性设计。Client 会周期性 renew 租约,一般是 10s。如果有多个 VM 挂载同一个云盘,会在任意一个物理机上使用一个虚拟机通过类似 OCFS2 的集群文件系统协调 IO 请求,对所有挂载的 VM 提供服务。

混合错误模型

不同 GFS 这种同步复制系统(f+1 份拷贝,允许 f 个失效)或其他的异步复制系统(2f +1 份拷贝,允许 f 个失效),Ursa 将副本和网络的失效区分对待,类似 VFT、XFT 这样。Ursa 中 client 会尝试写到所有副本,但会有一个超时时间,如果发生了超时,只要主副本成功即可 commit(上图的第六步)。同时 client 会通知 master 来修复问题,例如分配一个新的 replica 来替换。

与同步复制系统相比,这样可以在一些 replica 失效时始终保持系统可用,和异步复制系统相比持久性更好,具体来说,Ursa 可以在下面的情况保持持久性:

  1. 2f+1 中不多于 f 个副本失效
  2. (失效副本数)+(链接故障数)< 总副本数

(MatheMatrix: 这里我一开始没看懂,后来咨询了本文主要作者之一李慧霸博士,这里的核心含义是 Ursa 会先尝试同步复制,但超时则降级到异步复制,从而实现用户视角的服务可用性)

Ursa 复制协议

一般情况

初始化

当打开虚拟磁盘时,客户端将从 master 获得所有 chunk 的位置和视图号,然后一步查询每个 chunk 的所有 replica 的版本号和视图号。如果一个 chunk 的所有 replica 都有与 master 保存的一致的 view number,那么就可以从中任意选一个作为 primary(优先选择 SSD 上那个);否则,客户端会先向 cluster director 通知修复一致性,然后再重试。因为前面介绍的 single client 设计,client 其实是可以在任意时间更换 primary 的选择。

读和写

一旦视图号和 primary 得到确认,client 就可以向 primary 发送读写请求了。其中读请求优先由 primary 处理,写请求则会带着 client 的 view number 和 version number。还是上面的图,当收到写请求时,primary 会先检查本地的 view number、version number 是否和这个写请求一致,如果一直就在本地写,同时复制到 backup,增长 version number,最后回复给 client。backup 上的操作也是类似的。

然而,如果 client 的 view number 和请求的不一致,那么 primary 会拒绝并回复给客户端。client 需要从 master 获得现在 view number,如果 client 的 view number 大于 primary 上的,那么 primary 将尝试通过增量修复来更新自己的状态,增量修复就是从其他副本同步修改过的数据。如果 client 的 版本号等于 primary 的版本号减 1,那么 primary 本地不会做这个写请求(因为 primary 实际已经执行过这个请求),但是会转发到其他 backup。考虑到 single client 的前提,client 的版本和 primary 的差不会大于 1.

前面介绍的混合错误模型一方面提升了系统的可用性,但也增加了数据持久性的难度。具体来说:

  • 正常情况下,所有 replica 写入成功即 commit
  • 如果 primary 无法从所有 replica 获得成功写入的消息,那么会等待一定时间看能否获得半数成功(这样加上 primary 就是多数成功)
  • 与此同时,client 会通知 master 去修复不一致问题,或者可能最终就是分配一个新的 replica 替代失效的 replica
更换主副本

如果 client 读写发到了一个 fail 的 primary,那么 client 应该会自主更换 primary 到一个有最新数据的 backup 上,这样来实现高可用。考虑到使用了 SSD journal,即使此时是在 HDD backup 上,写入性能也不会太受影响,不过读取性能此时还是会受影响的。此时 cluster director 会同步在 SSD 上再创建一个新 replica,最终 client 会将 primary 重新移回到 SSD 上。

增量修复

为了支持增量修复,每个 replica(包括 SSD 和 HDD)都在内存里保存了一个 journal lite,cache 最近的写请求的 position、offset 和 version number。当一个 primary 或者 backup 从临时故障恢复时(例如网络分区),相关的 replica 会发送他自身当前的 version number 到其他 replica,其他 replica 收到时:

  1. 根据请求的 version number 查询 journal lite,找到修改的数据
  2. 根绝 journal lite 里的信息构造修复消息
  3. 修复消息里加上新的 version number,回复给 replica

如果无法通过 journa lite 找到数据(比如因为垃圾回收),那么就会传送完整数据。

Client-directed replication

前面提过这个事情,就是小于等于 8KB 的 IO 会由 client 直接送到所有副本,而不是通过 primary,其 version number 的维护是类似的,这样可以显著降低小 IO 的延迟。

错误恢复(View Change)

当发生了不一致问题时,master 会向具有最高 version number 的 replica 发送请求来做增量修复。反之如果是 chunk 有问题,那么 master 最终会分配一个新的 replica 来替代问题的,view number 会更新为 i+1,client 会在下次读写时得知这次 view number 的变化。

更具体地说,master 会从多数 replica 中收集 version number,然后选择其中最大的 version number vm 作为最新 state,将数据分发到新分配的 replica,如果需要的话,同时对已存在的 replica 做增量修复。

最后,所有的 replica 将 view number 更新为 i+1,因为它们此时有相同的数据(以及相同的 version number vm)。如果 master 和一个 replica 同时故障了,那么先修复 master,然后修复 replica。

故障恢复的核心思想就是 master 从 quorum 中找到最高的 version number,这个和异步复制系统是一样的。这个方案的缺点就是如果多数 replica crash 则存在丢数据的可能。相反同步复制系统可以在哪怕只有一个幸存者时工作。

下面这里的讨论看起来是如何通过识别 majority 中的永久 crash replica 来达到即使只有一个幸存者 依然可以找回数据,目前 Ursa 的做法还比较依赖手工。

讨论

特性丰富的 Client

Ursa 将很多特性例如 tiny write replication、striping、snapshot、client-side caching 都放在了 client。这和与 Qemu 的紧密集成有关。

Client 被设计为装饰器模型的 pluggable modules,所有 module 实现了公共的 read()/write() 接口,client 可以在线无感升级。

在线升级

Client

当 client 和 VMM(qemu)断开连接时,VMM 不会自动重连,所以想在不影响 Guest 的前提下升级还是比较难的。一种思路是把代码尽可能放到 shared library,这样升级就是 dynamic reloading,就很简单,但是也有很多限制,比如现有很多静态库不适合,接口不好升级等等。因此 Ursa 升级粒度是 process 而不是 library。Ursa 将 client 分为两个 process——core 和 shell。当升级时:

  1. core 停止从 VMM 接受新 IO,将 pending IO 完成
  2. 将状态保存到一个临时文件
  3. 退出并返回一个特殊返回码

shell 进程接收到 exit code 启动一个新的 core,从临时文件读取状态并恢复。

(MatheMatrxi:大体就是一个通过外包壳来分离核心代码的思路,升级期间 IO 应该都 pending 了)

Master

Master 升级是比较简单的,因为不涉及 IO 路径,只要关掉旧的立刻启动新的即可。升级期间磁盘创建和空间分配都会失败,不过客户端会自动重试。

Chunk Server

这个就比较难了,特别是在 chunk server 升级中发生 failure 时会对 failure handle 产生 confuse。所以 Ursa 设计了一个优雅热升级的策略:

  1. 发送一个特别信号到 chunk server;
  2. 关闭服务端口,停止接收 IO;
  3. 等待 in-flight io 结束;
  4. 等待新的 chunk server 启动;
  5. 检查新 chunk server 正常工作。

如果热升级成功了,旧的 chunk server 关闭所有连接( MatheMatrix:比如管理端口的连接)退出,客户端自动重连到新的 chunk server。反之如果失败了,旧的 chunk server 会杀死新的 chunk server,重新打开服务端口继续提供服务。

逐个升级

一个 Ursa 集群有众多服务进程做成,升级时每次升级一个进程,检查状态再升级下一个。所以升级集群可能会持续好几天时间。所有组件保持向后兼容性,到目前 Ursa 在一个部署超过两年的集群里对 replication protocol 升级过四次版本,每次升级都会增加新的操作并保证之前的操作不会发生变化来达到向后兼容。

发挥磁盘并行性

根据经验,Ursa 对 SATA SSD 和 PCIe SSD 会跑两个和四个进程,HDD 上跑单线程单进程,这个线程同时处理 journal replay、small write 和 replication( large write)。评估显示单线程进程只用电梯算法就可以跑满 HDD,多进程、多线程这些都会扰乱电梯算法,降低性能。

硬件可靠性

根据统计(下面的表格),HDD 贡献了 70% 的错误,比 SSD 高一个数量及。而 SSD 的 wear leveling(损耗平均)保护了 SSD 不会被频繁的 journal 写影响寿命。

image.png-20.3kB

不过虽然 SSD 平均故障率比 HDD 低,但是有一个潜在风险就是 SSD 可能因为固件 bug 造成同一批、同一个供应商的 SSD 批量出现问题。(MatheMatrix:然后举了腾讯云去年的例子 /捂脸)

为了解决这个影响,需要增加购买 SSD 的多样性,而且把 primary chunk 和 backup chunk 的 journal 存放在不同批次、供应商的 SSD 上。和全 SSD 存储不同的是 Ursa 可以通过 HDD 避免因为 SSD 固件 bug 造成的批量 SSD 下线造成数据丢失。

局限

  1. primary replica 故障恢复时客户就需要忍受 HDD 的性能影响,这要求我们尽可能缩短降级时间;
  2. 因为每个机器能提供的 SSD journal 大小有限制,SSD journal 无法持续性提供长时间很高的随机写(这个在美团云服务中比较少见),这个通过 HDD journal 和限速来 work around;
  3. SSD 的实效恢复要比全 SSD 更 urgent(MatheMatrix:和 1 好像一样)
  4. 对 SSD 的大小要求比 SSD cache 大很多

评估

这里做了一些测试,我直接展示数据吧,比较了 Ursa(SSD 和 SSD-HDD 混布)、Ceph 和 Sheepdog:

image.png-86.5kB

QD(queue depth)最大为 16 因为 qemu NBD driver 只能提供到 16。Sheepdog 和 Ceph 都是部署在全 SSD 环境的。(c) 这里表现比较差主要是因为 1MB 的 BS 大于 64KB 的 journal pass 阈值,所以 backup 都直接写入 HDD 跳过 journal 了。

image.png-84.2kB

image.png-84kB

图 10 主要是因为 Ursa 很依赖 journal 的 range query 的性能,因此和 PebblesDB 做了一个比较。

image.png-156.9kB

图 14 这里的选取了三种有代表性的 trace 数据 prxy_0proj_0mds_1 分别代表不同的 IO pattern。

相关工作

这里举了一些块存储、EC、混合存储、文件系统、对象存储和一致性的文章

总结

未来计划 RDMA/DPDK/SPDK 来达到超低延迟;提高顺序写的 IOPS。

Ursa 的一部分组件是开源在 http://nicexlab.com/ursa/ 的,包括:

  • st-pio 前面说的 libaio 的包装
  • st-redis hiredis 的包装
  • logging lib 支持东岱更新配置的轻量级 logging library
  • crc32 高性能的 crc32 library
  • testing script

后面是致谢和线性一致性的正面,包括 normal case 和故障恢复。