介绍分布式架构和高并发相关场景下的设计和问题定位的相关经验,持续更新。
在分布式架构下,我们不得不摆脱一些下层硬件为提供的屏障,而要去解决真实环境带来的难题。
如果将普通的程序比作是经典力学,那么研究高并发系统有点类似于研究热力学。当成千上万个过程彼此交互、竞争、等待,在有限的集群资源中将会产生不一样的场景。
一些传送门
- CPU 相关 Performance analysis and tuning on modern CPUs
- 内存相关 内存领域知识
- 通用架构相关 通用架构设计归纳
计算机工程工具
这些工具主要是:
- O11y
- Metrics
- Perf 相关
CPU Profiling
移动到专门的文章。
数学工具
统计学方法
介绍一个很有趣的case,它是一个压测程序中出现的问题。
工程技术和工程场景
阻塞
有一些典型的特征:
- 时间偏移
比如某些周期性的时间,如日志等,突然失去周期性,而在一瞬间打印了很多。
注意,阻塞的原因有很多:
- 不能正确解耦逻辑和 IO
- 队列积压,工作线程数偏少,导致对于每个任务而言,自己的等待时间会越来越大,似乎自己正在被阻塞
解耦 IO
如下所示,一瞬间打印了很多发送消息的日志,实际上这是由于没有正确解耦消息发送逻辑和 IO 逻辑。导致 IO 阻塞了同一个线程,从而积攒了大量的消息。特别地,如果因此产生了消息延迟,可能会雪崩。
在这个场景下,虽然我们使用了 ClickHouse 的线程池来处理异步的 IO,但由于线程池的队列大小过小,并且也没有指定等待超时时间,所以我们以为的异步实际上变成了同步。
当然,这里虽然是因为没有解耦 IO,但实际上也暴露了线程池的一些问题。
在解耦 IO 时,常常会将一些消息或者写入缓存到 Queue 或者 WriteBatch 以攒批 IO。对于这样的情况,在判断时首先需要检查对象是否已经被真的发送或者写入,因为这会导致后续完全不同的调查方向。
雪崩
可能发生在基于消息传输进行同步的系统中。不妨以 Raft 为例,如果因为一些阻塞的原因,一些发给 Follower 的 Append 消息没有被及时处理,很可能该 Follower 就会认为 Leader 挂了,从而发起选举。而选举会产生更多的消息,从而导致消息进一步积压,甚至会扩散到其他正常的 Region 中。
概率
在某个接口被高频调用时,应当认为其中小概率事件也是可能被发生的。
退出
当一个程序异常退出,但看不到异常日志时,考虑:
- 日志服务是否未初始化,或者该段异常日志被直接打印到标准错误
- 该程序是否由于异常信号或 OOM 退出
可以从 return code 或者 dmesg 或者 coredump 或者 stderr 等信息来看。 - 该程序是否主动退出
在一些程序中,会针对一些异常情况直接调用 exit 退出程序。此时可以用 gdb 去 hook_exit函数来查看退出时的堆栈。
调度
饥饿
一些程序会基于时间片的算法来进行调度。一些实现会从任务队列中取出所有等待的任务,执行这些任务,再检查是否超出时间片。如果执行这些任务本身的时间就比较长,甚至可能占用多个时间片,这就会导致调度算法接近于失效。
缓存
落盘
一般写入的阶段可以分为:
- Write Batch
- Memtable
在写入到 Memtable 之后就是可读的了。
在 Memtable 后,有的系统可能会异步 flush。所以需要辨别此时数据是否已经被写入。因为很多时候不是持久化越快越正确,因为很多东西必须要同时确定已经被持久化。 - Disk
因为磁盘往往比内存操作更慢,所以存储系统通常会考虑内存路径和磁盘路径。通常需要考虑下列问题:
- 持久化是否是原子的
如果涉及两个非原子的写入,则需要处理在写入间出现宕机的问题。 - 什么时候数据可以被读了
如果一个数据没有被持久化,那么它不能被读。否则一个客户端可能第一次读到该数据,而后服务器宕机重启之后出现 data loss 导致第二次读不到。这是不太好的一致性。
推(Push)和拉(Pull)
除了在分布式领域,推和拉也广泛存在在诸如流式数据输入、流式结果返回、远程调用中。
拉:
- 消费者在需要数据时,主动向生产者请求
- 优点
- 避免了 backpressure 的问题
- 实现简单
- 缺点和注意点
- 拉模式通常偏向于 lazy,消费端请求时,生产者才会生产,因此延迟大。
- 即使加入一些 eager 计算的成分,拉也在整个流传输链路的偏后位置。因此延迟较高,可提升空间小。
推:
- 由生产者主动生产数据,推送给消费者
- 优点
- 实时性强
这里有一个特别的场景。例如一些数据库写任务,通常 pipeline 较深,数据流传入之后,可能需要经历写 WAL、修改内存结构、修改磁盘结构等阶段,延迟会比较高。而如果使用推模式,则生产者可以同时把流推给写磁盘路径和内存路径,从而减少延迟。
- 实时性强
- 缺点和注意点
- 需要处理消费者速度慢而产生的背压问题
- 如果消费者端的处理出现问题,则需要实现复杂的重试逻辑。特别是对于流数据而言,可能还存在“部分成功”这样的错误类型。
一般实践中,会采用消息中间件,即使用一个可持久化的队列来进行缓冲,实际上属于 Push + Pull 混合模型。
锁
递归锁?
std::recursive_mutex 是否应该被使用呢?大部分在准备使用递归锁的时候,需要首先考虑是否可以设计出一个架构来避免使用递归锁。
死锁
在文章中论述。
读写锁?
常见的使用读写锁的错误是:
1 | if rw_lock.read().some_condition() { |
错误原因是,在 read 锁释放之后,write 锁加上之前,有其他线程可能修改这个条件。
一言蔽之,rw_lock 中,只要涉及修改,哪怕是通过比较,可能不修改的情况,都需要 write 锁。或者就是要有一个机制来把 read 锁升级到 write 锁。
另一个常见问题,是我们可能使用读写锁去维护一个 Cache。因为这个 cache 只需要被构建一次,所以就是一个线程持有 write 锁去构建,其他线程持有 read 锁去等待这个构建的完成。
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 的时候打印出足够的信息,甚至包括堆栈。
线程池
线程池的优点
- 避免重复创建线程的开销
- 作为异步任务的执行器
线程池/工作队列的缺点
线程是一种资源,获取线程不存在竞态问题,但其中的死锁问题却比较隐蔽。考虑在大小为 N 的线程池中:
- 执行 M > N 个 task,因为超过线程池容量,从而后面的 task 无法被调度
对于这个问题,可以在线程池中维护一个队列,从而会产生下面的问题:
- 执行 M > N 个彼此依赖的 task 构成的 job,如果被依赖的 task 没有被尽早执行,而在执行状态的线程因为依赖不满足而进入睡眠,但实际又没有释放线程池的容量。这就导致整个 job 可能死锁
- 所有的线程去 poll 一个队列,压力山大
对于问题1,可以有下面几种做法:
- 手动构建依赖的 DAG 图,按照顺序计算
- 使用协程,yield 并不释放线程资源
对于问题2,可以有下面的做法:
- 每个线程一个队列,但考虑到有线程可能会饥饿,所以会进化到 working steal 队列
线程池/工作队列的设计考虑
- 支持取一小部分线程组建新的线程池。
- 支持固定线程和临时线程,临时线程可以在空闲一段时间后自动销毁。
- 支持 Cancel
特别的,如果使用队列,则需要能处理 pending 在队列中的任务。我自己实现了个。 - 是否允许让某个一个 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 方法。
内存问题
见专题
文件描述符泄露
此类错误通常以 Too many open files 为形式体现。用户常常抱怨 ulimit 已经开到了最大,但仍然出现类似的问题。
相关检查方法
通过 lsof 查看有哪些文件句柄。
1 | lsof -p <PID> | wc -l |
strace
1 | strace -e trace=open,close -p <PID> |
检查 fd 的数量,判断是什么类型的 fd 泄露
1 | ls -l /proc/1/fd/ | grep "eventfd" | wc -l |
还需要关注的是那些已经被删除(unlink),但是依然有引用计数存在的文件。
1 | lsof -p | grep DEL |
套接口相关
服务发现
服务的优雅下线
节点在下线前,上面可能还有正在运行的任务,例如数据库服务器中可能还有正在执行的查询。如果直接重启,则会导致这些查询失败。如果查询本身很大,那么重试的代价会很高。但即使查询本身很小,也会暴露给客户一个 Service Unavailable 的报错,这是非常不友好的。
心跳
在有中心节点的服务中,通常是由边缘节点向中心节点上报心跳,以注册自己。
网络
TCP 重传
在 libutp 源码简析 和 TCP 的可靠传输 中介绍了 TCP 重传的几种可能原因。简单来说,重传的原因可能是超时,或者收到 3 个及以上重复的 ACK。超时的原因可能比较复杂,包括:
- 网络质量差,如中继节点发生丢包
- 对端系统繁忙或者 hang 了
- 对端的 socket buffer 满了,导致 rwnd 为 0。这里对端应该会发一个通告窗口为 0 的消息回来,触发坚持定时器
可以使用 eBPF 工具如 bcc 或者 bpftrace 跟踪内核函数 tcp_retransmit_skb,并记录当前重传的 socket 是哪个进程触发的。如
1 | sudo bpftrace -e ' |
在系统层面可以通过下面的命令得到总体重传的数量
1 | netstat -s | grep -i retrans |
Rate limiter
Backpressure
当生产速度高于消费速度时,生产者应该能够自动降速。背压的一些实现思路:
- 队列限长
- 窗口机制
- Request-n
dmesg
测试
重构测试
对正确性要求很高的子系统进行重构,如何设计测试?可以从几个层面来讨论:
- Unit test
单测用来保护逻辑。
首先对原来的子系统对外提供的每个接口进行设计单测,以获得其行为。这个单测是简单的,只需要设计不同的输入,并观测其返回值和副作用即可。
然后基于生成的单测来校验新的子系统。 - Random test
随机构造操作序列,并设定多种配置集合,运行测试。
这个测试既对子系统运行,也对使用不同子系统的上层系统进行测试。 - Chaos test
随机注入各种错误。 - 对拍
基于 Chaos test 中的宕机重启,使得程序在新老子系统中切换。
减少耦合
需要避免一个测试同时覆盖多个功能。例如有一个 Region Serde 的测试中允许写入 flexible 的扩展字段,并支持升降级。这个功能因为涉及到升降级和持久化,所以需要测试来保护。而这些 flexible 的字段可能属于某个 feature,这个 feature 本身也需要测试。
因此,最好的做法是 mock 一些 flexible 的字段,这些字段只用来测试。
注入的方式
#ifdef TEST宏- failpoint
- trait Mocker
- lambda
让被注入的函数接受一个 lambda 作为参数。正常逻辑中,该 lambda 为[](){},而测试逻辑中,该 lambda 为 mock 逻辑。
在 release 编译时,该 lambda 会被优化掉。
技术选型
序列化和反序列化(serde)
设计磁盘上的数据结构
架构漫谈
无状态的架构
业务系统通常是单服务器单库的架构,随着请求增多,需要进行扩容。
首先可以对服务器进行集群化和负载均衡。这个并不难,因为用户的请求从接入层打过来,通常已经经过了一系列路由、鉴权、限流、降级、LB 等过程。在业务层通常就是去处理每一个请求,其中涉及到与各种中间件和数据库交互。对业务层而言,这些只是 API 的调用。
但是业务虽然扩容了,所有的请求还是打到同一个数据库上,数据库成为瓶颈。在一段时间中流行在业务侧做些聚合啥的,但这会比较麻烦。
计算机科学领域的任何问题都可以通过增加一个间接的中间层解决。
因此,可以在服务器和数据库之间加上一层缓存。缓存的加入是为了减少数据库的压力。但显而易见,如果双写缓存和数据库,那会产生一致性问题:
- 比如先写库,再删缓存(Cache Aside Pattern),那么在这一段时间中缓存就是脏的。
- 又比如先删缓存再写库,那么只要这个操作不是原子的(大部分情况),那么就可能中间有个读线程在读库的时候再重新写一遍旧数据到缓存中。
一些方案能够尽可能处理缓存和数据库一致的问题。可以见分布式一致性和分布式共识协议。
随着请求进一步增多,数据库压力的进一步增大,这个时候就需要对数据库本身进行扩展。例如:
- 主从架构的 Replication
通常用来实现容错+读写分离带来的高可用。
数据库的主从方案可以通过 MySQL Proxy 等机制实现,阿里有一个 Canal 的数据库中间件,能够实现数据库的增量订阅和消费业务。 - 分库分表的 Partition
Reference
- https://zhuanlan.zhihu.com/p/264825380
PebbleDB 的测试方案 - https://www.cnblogs.com/chanshuyi/p/mycat_enlighten.html
描述了一个业务系统的架构升级之路