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

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

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

一些传送门

  1. CPU 相关 Performance analysis and tuning on modern CPUs

计算机工程工具

这些工具主要是:

  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,但实际上也暴露了线程池的一些问题。

在解耦 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. 作为异步任务的执行器

线程池/工作队列的缺点

线程是一种资源,获取线程不存在竞态问题,但其中的死锁问题却比较隐蔽。考虑在大小为 N 的线程池中:

  1. 执行 M > N 个 task,因为超过线程池容量,从而后面的 task 无法被调度

对于这个问题,可以在线程池中维护一个队列,从而会产生下面的问题:

  1. 执行 M > N 个彼此依赖的 task 构成的 job,如果被依赖的 task 没有被尽早执行,而在执行状态的线程因为依赖不满足而进入睡眠,但实际又没有释放线程池的容量。这就导致整个 job 可能死锁
  2. 所有的线程去 poll 一个队列,压力山大

对于问题1,可以有下面几种做法:

  1. 手动构建依赖的 DAG 图,按照顺序计算
  2. 使用协程,yield 并不释放线程资源

对于问题2,可以有下面的做法:

  1. 每个线程一个队列,但考虑到有线程可能会饥饿,所以会进化到 working steal 队列

线程池/工作队列的设计考虑

  1. 支持取一小部分线程组建新的线程池。
  2. 支持固定线程和临时线程,临时线程可以在空闲一段时间后自动销毁。
  3. 支持 Cancel
    特别的,如果使用队列,则需要能处理 pending 在队列中的任务。我自己实现了个
  4. 是否允许让某个一个 task 捕获线程池本身

避免捕获线程池本身

一个很经典的架构是一个 Submodule 中持有一个 ThreadPool。我们在全局上下文中持有这个 Submodule 的 shared_ptr,不放令为 Ptr。
假设我们通过 ThreadPool::addTask 向线程池注册了一个任务,并且这个任务中又捕获了 Ptr,这可能会导致问题。这个问题的必要条件还包括 ThreadPool 需要在它析构的时候 join 它所有申请线程,而这是一个通常的设计。
这个问题是如果这个任务本身是 Ptr 的最后一个 Owner,那么当这个 task 被返回的时候,将触发 ThreadPool 的析构。而这个析构会要求 join 包括这个 task 所属 worker 对应的线程。也就是说这个线程要自己 join 自己,显然这是不合理的,会触发一个 panic 或者死锁。比如 https://github.com/pingcap/tiflash/issues/8952。

我觉得解决这个问题的最简单的办法就是强制引入一个显式的 shutdown 方法。

内存问题

空指针

C++ 中的空指针影响会比较大。比如对 nullptr 调用 operator->() 就会得到一个 segfault,对应的 addr 可能漂到不知道哪里去了,很难定位问题。

特别地,C++ 还不太容易实现 Rust 中的 NotNull 指针,从而减少心智负担。这是因为 C++ 本身的移动语义会将移动后的对象的指针设置为空,而这就导致 NotNull 无法移动。而 unique_ptr 又是天生只支持移动的,这就导致了 NotNull 和 unique_ptr 无法兼容。Rust 能支持是因为编译期保证了使用移动后的对象一定是不能通过编译的。

一般有下面的一些做法:

  1. 使用 ASAN 进行检测。但这需要代码本身的 coverage 足够高,实际上要求有一个比较好的写单测或者做集成测试的习惯
  2. 使用线程池维护对象,避免使用任何形式的 shared_ptr、unique_ptr 以及裸指针
  3. 使用一些 not_null ptr 的实现,这些实现能够 workaround 掉 unique_ptr 的相关问题
    https://github.com/bitwizeshift/not_null/blob/master/include/not_null.hpp 这样的库可以选择使用 check_not_null 在创建的时候执行运行期检查,也可以使用 assume_not_null 执行有限的编译期检查(但如果值在编译期无法确定,则编译期检查无效)。
    这个库也支持移动语义
    1
    2
    3
    4
    5
    6
    7
    8
    // Should never be null, but not yet refactored to be 'not_null'
    auto old_api(std::unique_ptr<Widget> p) -> void;

    auto new_api(cpp::not_null<std::unique_ptr<Widget>> p) -> void
    {
    // Extract the move-only unique_ptr, and push along to 'old_api'
    old_api(std::move(p).as_nullable());
    }

特别地,我不觉得 if likely(!ptr) throw_or_panic(); 这样的写法有太大问题,因为现代 CPU 的 speculation 机制让这个 if 的开销变得很低。但毫无疑问,每次都要判断,无疑加重了开发人员的心智负担,每一个函数的开头需要防御性编程写一堆 check。而实际上至少从某一层开始,工具函数就可以要求传入的 ptr 是 not null 了。特别地,对于一个全新 init 的工程,可以始终通过 std::optional<not_null> 来代替 std::shared_ptr

OOM

线程级别的内存分配记录 – allocatedp

一种方法是基于 jemalloc 的 mallctl 调用。该调用有两种方式,其中 thread.allocated 可以立即返回当前的 caller thread 分配了多少内存。而 thread.allocatedp 可以返回一个指针,解引用这个指针可以返回对应线程当前分配了多少内存。通过第二种方式可以避免频繁的 mallctl 调用,也可以实现从其他线程进行观测。但其中比较困难的一点就是如何监测线程的启动和释放,从而判断对应的指针是否能够被读取。一种做法是包装线程池的 API,在每个线程启动和释放的时候加上 hook。

此外,这种方式对于 Rust 程序会有一些问题:

  1. Rust 的移动语义会导致一些线程分配的内存会被另一个线程释放
    比如返回一个 T : Send 给另一个线程作为 Result。通常在需要使用线程池进行处理的逻辑中。
  2. 协程跑在 executor 上面,难以分清辨认出具体的用途

线程级别的内存分配记录 – arena

另一种方法是通过 arena.createthread.arena 为每一个线程绑定对应的 arena。

可以使用 mallocx 为某个模块指定对应的 arena。

可以自定义 allocator 来适配 C++ 的方式。

TiKV 的 benchmark 展示这不会产生很明显的 overhead。

内存泄露

并不是野指针才算内存泄露。如果有一些结构存在于某些队列或者哈希表中,但并不是所有路径都会最终回收掉该结构,那么同样可以认为存在内存泄露。

测试

重构测试

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

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

减少耦合

需要避免一个测试同时覆盖多个功能。例如有一个 Region Serde 的测试中允许写入 flexible 的扩展字段,并支持升降级。这个功能因为涉及到升降级和持久化,所以需要测试来保护。
而这些 flexible 的字段可能属于某个 feature,这个 feature 本身也需要测试。
因此,最好的做法是 mock 一些 flexible 的字段,这些字段只用来测试。

注入的方式

  1. #ifdef TEST
  2. failpoint
  3. trait Mocker
  4. lambda
    让被注入的函数接受一个 lambda 作为参数。正常逻辑中,该 lambda 为 [](){},而测试逻辑中,该 lambda 为 mock 逻辑。
    在 release 编译时,该 lambda 会被优化掉。

架构漫谈

无状态的架构

一个最原始的系统通常是单服务器单库的架构。但随着用户请求越来越大,需要逐步进行升级。

首先可以对服务器进行集群化和负载均衡。这个并不难,因为用户的请求从接入层打过来,通常已经经过了一系列路由、鉴权、限流、降级、LB 等过程。在业务层通常就是去处理每一个请求,其中涉及到与各种中间件和数据库交互。对业务层而言,这些只是 API 的调用。

但是业务虽然扩容了,所有的请求还是打到同一个数据库上,数据库成为瓶颈。一般可以在业务侧做些聚合啥的,但这并不是一个较为通用的方案。

计算机科学领域的任何问题都可以通过增加一个间接的中间层解决。可以在服务器和数据库之间加上一层缓存。

缓存的加入是为了减少数据库的压力。但显而易见,如果双写缓存和数据库,那么就一定产生一致性问题:

  1. 比如先写库,再删缓存(Cache Aside Pattern),那么在这一段时间中缓存就是脏的。
  2. 又比如先删缓存再写库,那么只要这个操作不是原子的,其实大多数情况下都不是原子地,那么就可能中间有个读线程在读库的时候再重新写一遍旧数据到缓存中。

有一些方案能够尽可能处理缓存和数据库一致的问题。可以见分布式一致性和分布式共识协议

随着请求进一步增多,数据库压力的进一步增大,这个时候就需要对数据库本身进行扩展。例如:

  1. 主从架构的 Replication
    通常用来实现容错+读写分离带来的高可用。
    数据库的主从方案可以通过 MySQL Proxy 等机制实现,阿里巴巴有一个 Canal 的数据库中间件,能够实现数据库的增量订阅和消费业务。
  2. 分库分表的 Partition

Reference

  1. https://zhuanlan.zhihu.com/p/264825380
    PebbleDB 的测试方案
  2. https://www.cnblogs.com/chanshuyi/p/mycat_enlighten.html
    描述了一个业务系统的架构升级之路