分布式架构和高并发相关场景

介绍分布式架构和高并发相关场景下的设计和问题定位的相关经验,持续更新。

在分布式架构下,我们不得不摆脱一些下层硬件为提供的屏障,而要去解决真实环境带来的难题。
如果将普通的程序比作是经典力学,那么研究高并发系统有点类似于研究热力学。当成千上万个过程彼此交互、竞争、等待,在有限的集群资源中将会产生不一样的场景。

计算机工程工具

这些工具主要是:

  1. O11y
  2. Metrics
  3. Perf

Trace 任意函数的执行时间

该方案整理自某同事的 idea。

考虑下面的场景,我们需要查看某动态链接库 /path/to/libtiflash_proxy.sohandle_pending_applies 函数每次调用的耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
perf probe --del 'probe_libtiflash_proxy:*'
BIN=/path/to/libtiflash_proxy.so
TOKEN=handle_pending_applies
ITER=0
objdump $BIN --syms | grep $TOKEN | awk '{print $6}' | while read -r tok ; do
ITER=$(expr $ITER + 1)
NAME=$TOKEN\_$ITER
echo $NAME, $TOKEN, $ITER, $TOKEN\_$ITER
perf probe -x $BIN --no-demangle $NAME=$tok
perf probe -x $BIN --no-demangle $NAME=$tok%return
done
perf record -e probe_libtiflash_proxy:\* -aR sleep 10
perf script -s perf-script.py

附上 perf-script.py

分析某段时刻的调用栈

生成火焰图

记录线程名

有一些方法设置线程名,包括:

  1. pthread_setname_np
  2. prctl

出于下列原因,建议线程名具有唯一性:

  1. 诸如一些线程池的实际工作内容不一样,最好以数字区分。
  2. 有一些库会扩展 std::mutex,记录上锁的线程名,用来避免重复加锁。当然这个并不好,最好用线程 id。

在一些很老的 c 库中,没有提供 pthread_setname_np 函数。诸如 CK 之类的会写一个 dummy 的函数来替换,这可能导致一些情况下不能设置成功线程名。

数学工具

统计学方法

介绍一个很有趣的 case,它是一个压测程序中出现的问题

Intuition

阻塞

有一些典型的特征:

  1. 时间偏移
    比如某些周期性的时间,如日志等,突然失去周期性,而在一瞬间打印了很多。

注意,阻塞的原因有很多:

  1. 不能正确解耦逻辑和 IO
  2. 队列积压,工作线程数偏少,导致对于每个任务而言,自己的等待时间会越来越大,似乎自己正在被阻塞

解耦 IO

如下所示,一瞬间打印了很多发送消息的日志,实际上这是由于没有正确解耦消息发送逻辑和 IO 逻辑。导致 IO 阻塞了同一个线程,从而积攒了大量的消息。特别地,如果因此产生了消息延迟,可能会雪崩。
在这个场景下,虽然我们使用了 ClickHouse 的线程池来处理异步的 IO,但由于线程池的队列大小过小,并且也没有指定等待超时时间,所以我们以为的异步实际上变成了同步。

在解耦 IO 时,常常会将一些消息或者写入缓存到 Queue 或者 WriteBatch 以攒批 IO。对于这样的情况,在判断时首先需要检查对象是否已经被真的发送或者写入,因为这会导致后续完全不同的调查方向。

雪崩

可能发生在基于消息传输进行同步的系统中。不妨以 Raft 为例,如果因为一些阻塞的原因,一些发给 Follower 的 Append 消息没有被及时处理,很可能该 Follower 就会认为 Leader 挂了,从而发起选举。而选举会产生更多的消息,从而导致消息进一步积压,甚至会扩散到其他正常的 Region 中。

概率

在某个接口被高频调用时,应当认为其中小概率事件也是可能被发生的。

退出

当一个程序异常退出,但看不到异常日志时,考虑:

  1. 日志服务是否未初始化,或者该段异常日志被直接打印到标准错误
  2. 该程序是否由于异常信号/OOM退出
    可以从 return code 或者 dmesg 或者 coredump 或者 stderr 等信息来看。
  3. 该程序是否主动退出
    在一些程序中,会针对一些异常情况直接调用 exit 退出程序。此时可以用 gdb 去 hook _exit 函数来查看退出时的堆栈。

调度

饥饿

一些程序会基于时间片的算法来进行调度。一些实现会从任务队列中取出所有等待的任务,执行这些任务,再检查是否超出时间片。如果执行这些任务本身的时间就比较长,甚至可能占用多个时间片,这就会导致调度算法接近于失效。

缓存

落盘

一般写入的阶段可以分为:

  1. Write Batch
  2. Memtable
    在写入到 Memtable 之后就是可读的了。
    在 Memtable 后,有的系统可能会异步 flush。所以需要辨别此时数据是否已经被写入。因为很多时候不是持久化越快越正确,因为很多东西必须要同时确定已经被持久化。
  3. Disk

因为磁盘往往比内存操作更慢,所以存储系统通常会考虑内存路径和磁盘路径。通常需要考虑下列问题:

  1. 持久化是否是原子的
    如果涉及两个非原子的写入,则需要处理在写入间出现宕机的问题。
  2. 什么时候数据可以被读了
    如果一个数据没有被持久化,那么它不能被读。否则一个客户端可能第一次读到该数据,而后服务器宕机重启之后出现 data loss 导致第二次读不到。这是不太好的一致性。

使用什么锁?

std::recursive_mutex 是否应该被使用呢?我觉得大部分在准备使用递归锁的时候,需要首先考虑架构问题。

死锁怎么办?

通过 gdb 可以找到对应 mutex 结构中的 owner,对应的值表示 LWP 的编号。
对于一些程序,可能 debug info 被优化掉了,此时可以选择

  1. 根据提示的行号,拷贝一份对应的源码到指定位置
  2. 自己编译一个同样 layout 的对象,然后 load 进去解析

Panic

避免依赖 coredump

首先,如果 coredump 很大,它常常会被截断,即使我们设置了 ulimit -c unlimited

1
BFD: Warning: core.36322 is truncated: expected core file size >= 14835945472, found: 1073742080

其次,在发生诸如 segfault 时,我们也未必需要 coredump 才能拿到堆栈。例如可以启动一个专门的 sigHandler 线程,并配合 libunwind 来在其他线程出现 segfault 的时候打印出足够的信息,甚至包括堆栈。

线程池

线程池的设计考虑几点:

  1. 支持取一小部分线程组建新的线程池。
  2. 支持固定线程和临时线程,临时线程可以在空闲一段时间后自动销毁。

测试

重构测试

对正确性要求很高的子系统进行重构,如何设计测试?可以从几个层面来讨论:

  1. Unit test
    单测用来保护逻辑。
    首先对原来的子系统对外提供的每个接口进行设计单测,以获得其行为。这个单测是简单的,我们只需要设计不同的输入,并观测其返回值和副作用即可。
    然后基于生成的单测来校验新的子系统。
  2. Random test
    随机构造操作序列,并设定多种配置集合,运行测试。
    这个测试既对子系统运行,也对使用不同子系统的上层系统进行测试。
  3. Chaos test
    随机注入各种错误。
  4. 对拍
    基于 Chaos test 中的宕机重启,使得程序在新老子系统中切换。

有趣的 case

一个 Eager 落盘导致的问题

并不是所有时候,eager 落盘都能保证正确性问题。下面就是一个例子。
首先介绍背景,在 TiKV 的实现中有两个 engine,KVEngine 存储 KV Meta 和 KV Data,RaftEngine 存储 Raft Meta 和 Raft Data。其中有一个 Apply Snapshot 的场景会同时原子地修改这两个 Engine,但可惜这两个 Engine 无法做到原子地落盘。并且因为两个 Engine 中都存有 Meta 和 Data,所以任意的先后顺序,都会导致数据不一致。这里的解决方式是将 RaftEngine 中的的 Raft Meta 写到 KVEngine 中,称为 Snapshot Meta。写入的时候,会先写 KVEngine,再写 RaftEngine。当在两个非原子写入中间出现宕机,从而不一致的时候,会使用 KVEngine 中的 Raft Meta 替换 RaftEngine 中的 Raft Meta。

Apply Snapshot阶段开始时,它会调用 clear_meta 删除掉 KV Meta、Raft Meta 和 Raft Data,但这个删除是不应该立即落盘的,而是在 WriteBatch 里面。在这之后,还会再往 WriteBatch 中写入 Snapshot Meta 等。这些写入会被一起发送给一个 Async Write 写入。我们的错误是,在实现删除 Raft Engine 数据时,并不是写 Write Batch,而是直接写盘。在 clear_meta 之后系统又立即宕机了。这样重启恢复后,就会看到空的 Raft Meta 和 Raft Data,但 KV Meta 却还存在。这是一个 Panic 错误,因为两个 Meta 不一致了。

这样的错误是难以调查的,我们可以加日志获得重启后从磁盘中读到的结果,但仍然不知道这个结果是如何被写入的。查的方式是脑补,也就是针对这样的场景,假设在不同时刻宕机,考虑会出现什么样的持久化状态。
这里,KV Meta 的落盘信息是有的,它可能是没清就宕机了,也可能是写完新的数据之后宕机的。考量这个可以看一些 Meta 信息有没有写入,比如我们发现 Snapshot Meta 并不存在,因此说明是前一种情况。既然如此,为什么 Raft Meta 和 Data 都没了呢?只能说明是 Raft 的清早了。

当然,这里有个迷惑点,就是 KV Meta 提示当前是在 Applying Snapshot 状态,而如果我们是第一种情况的话,这个 Applying 状态应该还没有被写入。这个原因是这个实例发生了多次重启,在 T-2 次启动后 Apply Snapshot 时,KVEngine 和 RaftEngine 都落盘成功了,但是后续的流程没进行下去就重启了。所以在 T-1 次启动会重新 Apply Snapshot,但这一次甚至没到落盘就重启了,而 Snapshot Meta 是金标准。然后就是我们见到的 T 次启动的错误。这启示我们不能只通过一个元数据来判断当前集群的状态,而是要检查所有的元数据,来石锤当前状态是如何得到的。

Reference

  1. https://zhuanlan.zhihu.com/p/264825380
    PebbleDB 的测试方案