当前位置:   article > 正文

分布式技术 3:高并发系统的设计思路以及C++ 实现_分布式系统 c++

分布式系统 c++

Hello,大家好,欢迎来到“自由技艺“的学习小馆。今天我们来聊一聊软件系统中的高并发,具体内容包括高并发的设计思路、高并发的关键技术以及高并发实践。本文内容信息量比较大,涉及负载均衡、缓存、流控等技术,还有部分 C++ 代码实现,需要读者慢慢消化。

1 什么是高并发

“并发”一词最早来源于操作系统领域,指一段时间内多任务交替执行的现象。后来,高并发用来指大流量、大规模业务请求的场景,比如春运抢票、电商“双十一”抢购,秒杀促销等。
高并发的衡量指标主要有两个:一是系统吞吐率,比如 QPS(每秒查询率)、TPS(每秒事务数)、IOPS(每秒磁盘进行的 I/O 次数)。另外一个就是时延,也就是从发出 Reques 到收到 Response 的时间间隔。一般而言,用户体验良好的请求、响应系统,时延应该控制在 250 毫秒以内。
什么样的系统才能称之为高并发呢?
它取决于系统所承载的业务类型和硬件能力。通常,数据库单机每秒也就能抗住几千这个量级,而做逻辑处理的单台服务器每秒抗几万、甚至几十万都有可能,而消息队列等中间件单机每秒处理个几万没问题,所以我们经常听到每秒处理数百万、数千万的消息中间件集群,而像阿里的 API 网关,每日百亿请求也有可能。

2 高并发的设计思路

高并发的设计思路有两个方向:

垂直方向扩展
水平方向扩展
垂直方向:提升单机能力
这个好理解,就是最大化单台服务器的性能,主要有两个手段:一是提升硬件能力,比如采用核数更多、主频更高的服务器,提升网络带宽,扩大存储空间;二是软件性能优化(请翻阅本账号之前的文章),尽可能榨干 CPU 的每一个 Tick。
水平方向:分布式集群
为了降低分布式软件系统的复杂性,一般会用到“架构分层和服务拆分”,通过分层做“隔离”,通过微服务实现“解耦”,这样做的一个主要目的就是为了方便扩容。然而一味地加机器扩容有时也会带来额外的问题,比如系统的复杂性增加、垂直方向的能力受限等。

3 高并发的关键技术

一个最简易的网络服务程序,用户可以直连服务器,数据直接写磁盘文件,也用不上数据库。但 12306 的购票系统显然不能这么做,它要扛住每秒成千上万次的事务或请求,那要如何实现呢?
其实,核心思路上文已经讲过了,就是层次划分(垂直方向) + 功能划分(水平方向)。首先,服务器的数量肯定不止一台,大量用户访问不同服务时,需要将这些用户负荷分担到集群里的不同服务实例上,这就引入了高并发系统中的第一项技术:负载均衡。当然,在负载均衡之前首先要做到服务发现。

3.1 负载均衡

DNS负载均衡
客户端通过 URL 发起网络服务请求的时候,请求报文先被送去 DNS 服务器做域名解析,DNS 会按一定的策略(比如就近策略)把 URL 转换成 IP 地址,同一个 URL 会被解析成不同的 IP 地址,这便是 DNS 负载均衡,它是一种粗粒度的负载均衡,它只用 URL 前半部分,因为 DNS 负载均衡一般采用就近原则,所以通常能降低时延,但 DNS 有 Cache,所以也会存在更新不及时的问题。
硬件负载均衡
通过布置特殊的负载均衡设备到机房做负载均衡,比如 F5(智能交换机),这种设备贵,性能高,可以支撑每秒百万并发,还能做一些安全防护,比如防火墙。
软件负载均衡
软件负载均衡可以作用于 ISO 7 层协议中的四层(比如 LVS)和七层(比如 NGINX),软件负载均衡配置灵活,扩展性强。这里重点提下 Ngix 中的负载均衡技术。
Nginx 采用的是反向代理技术,反向代理负载均衡技术是把将来自Internet上的连接请求以反向代理的方式动态地转发给内部网络上的多台服务器进行处理,从而达到负载均衡的目的。具体是怎么运行的呢?其实当 Nginx 启动后,其工作进程是由配置文件对其进行初始化的,主进程处理配置文件中的读取、端口绑定等特权操作,之后创建一小组子进程,由这些子进程进行请求的处理,同时缓存加载器加载硬盘中缓存到内存中,接着退出,保证资源开销始终保持着较低的状态。可以看出,创建的子进程其实在负责所有的工作,处理网络连接、硬盘读写操作、以及上游服务器通信。
所以,完整的负载均衡链路有多级组成,从下往上,依次是 DNS 负载均衡、F5、LVS/SLB(阿里云的 API 网关) 、NGINX。
不管是选择一种还是多种负载均衡策略,逻辑上,我们都可以视为负载均衡层,通过添加负载均衡层,我们将负载均匀分散到了后面的服务集群,从而具备基础的高并发能力,但这还远远不够。

3.2 数据库层面:分库分表 + 读写分离

负载均衡看起来很美好,实际上它只能解决无状态服务的水平扩展问题。但我们的系统不全是无状态的,这就得靠有状态的数据库解决。然而,存储也有可能成为系统的瓶颈,因为数据库的单机 QPS 一般不高,也就几千,所以,我们需要对有状态的存储做分片路由,于是引入了分库分表、读写分离的技术。
简单理解,就是把一个库分成多个库,部署在多个数据库服务上,主库承载写请求,从库承载读请求。从库可以挂载多个,因为很多场景写的请求远少于读的请求,这样就把对单个库的压力降下来了。如果写的请求上升就继续分库分表,如果读的请求上升就挂更多的从库,但数据库天生不是很适合高并发,而且数据库对机器配置的要求一般很高,导致单位服务成本高,所以,这样加机器抗压力成本太高,还得另外想办法。

3.3 读多写少:缓存

一般系统的写入请求远少于读请求,针对写少读多的场景,很适合引入缓存集群。
在写数据库的时候同时写一份数据到缓存集群里,然后用缓存集群来承载大部分的读请求,因为缓存集群很容易做到高性能,所以,这样的话,通过缓存集群,就可以用更少的机器资源承载更高的并发。
缓存的命中率一般能做到很高,而且速度很快,处理能力也强(单机很容易做到几万并发),是理想的解决方案。
CDN 本质上就是缓存,被用户大量访问的静态资源缓存在 CDN 中是目前的通用做法。
话分两头,任何技术都不会绝对地完美,缓存技术也不例外:
一致性问题
简单来说,就是数据库和缓存内容更新不同步,具体解决办法在我之前的文章中有介绍过。
缓存穿透
当我们查询某个数据时,先从缓存中查找,如果找不到,就会穿透缓存直接从数据库查找。如果有人利用这个漏洞,大量查询缓存中不存在的数据,就会对数据库造成很大压力,甚至把数据库搞挂。
解决办法:采用布隆过滤器或者更简单的方案,查询不存在的 key,也把空结果写入缓存(可以设置比较短的过期淘汰时间),从而降低 Cache Miss.
缓存雪崩
如果大量缓存在一个时刻同时失效(老化),则请求会转到 DB,则对 DB 形成压迫,导致雪崩。
简单的解决方案是为缓存失效时间添加随机值,降低同一时间点大量缓存同时失效的概率。
有了缓存,我们的高并发系统似乎很厉害了,然而,问题还有很多:缓存是针对读数据,如果写数据的并发压力很大,怎么办?

3.4 消息中间件

消息中间件正是为了解决高写入问题。常用的消息中间件技术,就是 MQ 集群,它是非常好的做写请求异步化处理,实现“削峰填谷”的效果。
消息队列能做解耦,在只需要最终一致性的场景下,很适合用来配合做流控。
假如说,每秒是 1 万次写请求,其中比如 5 千次请求是必须请求过来立马写入数据库中的,但是另外 5 千次写请求是可以允许异步化等待个几十秒,甚至几分钟后才落入数据库内的。
另外,业界有很多著名的消息中间件,比如 ZeroMQ,rabbitMQ,kafka 等。
值得注意的是消息队列能够支持高并发写请求的前提是,允许异步化。
解决了高并发的读、写请求之后,我们的工作还没结束,还有很重要的一环,就是流控,这也是最后的办法了。

3.5 流控

常见的流控算法有 4 种。

计数器算法(固定窗口)
这是最简单的一种限流手段。在一段时间内计数,当达到设定的限流值时,触发限流策略,下一个周期开始时,进行清零,重新计数。然而,计数器限流存在时间临界点的问题,比如每分钟限速 1000 个请求,第 59 秒来了 1000 个请求,第 61 秒又来了 1000 个请求,59 秒到 61 秒瞬间来了 2000 个请求。

#include <chrono>

class Counter {
public:
    std::chrono::time_point<std::chrono::steady_clock> start, end;
    std::chrono::duration<float> duration;
    Counter() {
        start = std::chrono::high_resolution_clock::now();
    }
    // 每秒限制 50 个请求
    long limitCount = 50;
    long interval = 1000; // 时间窗口大小 1000 ms
    long reqCount = 0;
 
    bool grant() {
        end = std::chrono::high_resolution_clock::now();
        duration = end - start;
        int ms = duration.count() * 1000.0f;
        if (ms < interval) {
            if (reqCount < limitCount) {
                ++reqCount;
                return true;
            } else {
                return false;
            }
        } else {
            start = std::chrono::high_resolution_clock::now();
            reqCount = 0;
            return false;
        }
    }
   
};

int main()
{
    Counter c;
    for (int i = 0; i < 100; i++) { // 总共 100 个请求
        if (c.grant()) {
            std::cout << "执行中" << '\n';
        } else {
            std::cout << "限流中" << '\n';
        }
    }
    std::cin.get();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46

滑动窗口算法

滑动窗口是在固定窗口算法基础上,把时间片划分得更细,而且时间窗口可以移动。
计数器算法其实就是滑动窗口算法的一个特例,只有一个时间窗口。当滑动窗口的格子划分得越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
滑动窗口由于需要存储多份的计数器,每个窗口一份计数器,所以滑动窗口在实现上需要更多的存储空间,滑动窗口的精度越高,需要的存储空间就越大。

漏桶算法
滑动窗口虽然避免了临界点问题,但还是依赖时间片,漏桶算法在这方面比滑动窗口而言,更加先进。访问请求到达时直接放入漏桶,如当前容量已达到上限(限流值),则进行丢弃(触发限流策略)。漏桶以固定的速率进行释放访问请求(即请求通过),直到漏桶为空。

#include <chrono>

class LeakBucket {
public:
    std::chrono::time_point<std::chrono::steady_clock> start, end;
    std::chrono::duration<float> duration;
    LeakBucket() {
        start = std::chrono::high_resolution_clock::now();
    }
    long capacity = 10;  // 桶的容量
    long water = 0;      // 当前水量(当前累积请求数)
    long rate = 5;       // 水流出速度,/ms
    
    bool grant() {
        end = std::chrono::high_resolution_clock::now();
        duration = end - start;
        int ms = duration.count() * 1000.0f;
        water = (water - ms * rate > 0) ? (water - ms * rate) : 0;
        // 先执行漏水,计算剩余水量
        start = end;
        if ((water + 1) < capacity) {
            water += 1;
            return true;
        }
        else {
            return false;
        }
    }
};

int main()
{
    LeakBucket lb;
    for (int i = 0; i < 100; i++) { // 总共 100 个请求
        if (lb.grant()) {
            std::cout << "执行中" << '\n';
        } else {
            std::cout << "限流中" << '\n';
        }
    }
    std::cin.get();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

令牌桶算法
和漏桶算法正好相反,程序以 r 的速度向令牌桶中增加令牌,直到令牌桶满。请求到达时向令牌桶请求令牌,如获取到令牌则通过请求,否则触发限流策略。

#include <chrono>

class TokenBucket {
public:
    std::chrono::time_point<std::chrono::steady_clock> start, end;
    std::chrono::duration<float> duration;
    TokenBucket() {
        start = std::chrono::high_resolution_clock::now();
    }
    long capacity = 10;  // 桶的容量
    long rate = 20;     // 令牌放入速度,/ms
    long tokens = 5;    // 当前令牌数量
    
    bool grant() {
        end = std::chrono::high_resolution_clock::now();
        duration = end - start;
        int ms = duration.count() * 1000.0f;
        tokens = (tokens + ms * rate < capacity) ? (tokens + ms * rate) : capacity;
        start = end;
        if (tokens < 1) {
            return false;
        }
        else {
            tokens -= 1;
            return true;
        }
    }
};

int main()
{
    TokenBucket tb;
    for (int i = 0; i < 100; i++) { // 总共 100 个请求
        if (tb.grant()) {
            std::cout << "执行中" << '\n';
        } else {
            std::cout << "限流中" << '\n';
        }
    }
    std::cin.get();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

流控算法运行结果
在这里插入图片描述

4 高并发的实践经验

接入-逻辑-存储是经典的互联网后端分层架构,但随着业务规模的提高,逻辑层的复杂度也上升了,所以,针对逻辑层的架构设计也出现很多新的技术和思路,最典型的就是服务拆分,如微服务。除此之外,也有很多业界的优秀实践,比如微信服务器通过协程改造,极大的提高了系统的并发度和稳定性,另外,缓存预热,预计算,批量读写(减少I/O),池技术等也广泛应用在实践中,有效的提升了系统并发能力。
另外,构建漏斗型业务系统,从客户端请求到接入层,到逻辑层,再到 DB 层,层层递减,过滤掉请求,遵循“尽早发现尽早过滤”的原则。

5 总结

总结下,这篇文章的内容基本上涵盖了高并发系统的主流设计思路和技术,这对我们宏观上理解互联网后台的整体架构非常有帮助。
然而,作为一名程序员,最根本的技能还是编程,比如文中提到的各类数据库、各种消息中间件都是非常牛逼的基础组件。我们要向优秀的软件学习,学习它们巧妙的数据结构和算法设计,然后熟练运用到自己的业务代码中。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/177879
推荐阅读
相关标签
  

闽ICP备14008679号