「高并发系统设计40问」学习笔记 (下)

Posted by 小石匠 on 2022-04-15

参考资料链接:高并发系统设计40问 系统设计题精选

书籍豆瓣链接:

开始学习时间:

预计完成时间:

实际完成时间:

五、分布式服务

5.1 每秒1w次请求的系统需要服务化拆分嘛

5.1.1 一体化架构的痛点

数据库连接数可能成为系统瓶颈

增加了研发的成本

增加系统运维的压力

5.1.2 如何使用微服务化解决这些痛点

数据库垂直分库,业务层拆分

增加中间层,将不同数据库的直接访问,转变为服务化抽象出公共服务

5.2 微服务化后,系统架构如何改造

5.2.1 微服务拆分原则

高内聚,低耦

先粗略拆分,随着业务的发展,再逐渐细化

拆分的过程中,尽量避免影响产品的日常功能迭代

服务接口的定义要具备可扩展性

5.2.2 微服务化带来的问题和解决思路

5.2.2.1 服务间的调用变成进程间的网络调用

引入服务注册中心,管理的是服务完整的生命周期,包括对于服务存活状态的检测

5.2.2.2 服务间依赖关系错综复杂

引入服务治理体系,采用熔断、降级、限流、超时控制的方法,使得问题被限制在单一服务中

5.2.2.3 调用链路问题定位

引入分布式追踪工具,以及更细致的服务端监控报表

5.3 实现支持10w QPS的RPC框架

提升 RPC 框架的性能,需要从 网络传输和序列化 两方面来优化

5.3.1 网络传输优化

选择高性能的 I/O 模型,这里我推荐使用同步多路 I/O 复用模型

调试网络参数,比如将 tcp_nodelay 设置为 true,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)

5.3.2 合适的序列化方式

性能要求不高,在传输数据占用带宽不大的场景下,可以使用 JSON

性能要求比较高,那么使用 Thrift 或者 Protobuf 都可以

一些存储的场景下,比如说你的缓存中存储的数据占用空间较大,那么你可以考虑使用 Protobuf 替换 JSON,作为存储数据的序列化方式

5.4 注册中心:分布式系统如何寻址

5.4.1 Nginx反向代理与注册中心

Nginx通过在配置文件中配置应用服务器的ip

注册中心:提供了服务地址的存储;存储内容发生变化时,可以将变更的内容推送给客户端

5.4.2 如何做服务管理

心跳机制

注册中心为每一个连接上来的 RPC 服务节点,记录最近续约的时间 ,RPC 服务节点在启动注册到注册中心后,就按照一定的时间间隔(比如 30 秒),向注册中心发送心跳包

5.5 分布式Trace

5.6 负载均衡

5.6.1 负载均衡种类

5.6.1.1 代理类负载均衡服务

  • LVS

LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载,

4层负载均衡基本就是基于 IP + 端口进行负载均衡,LVS在四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器

  • Nginx

Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载

7层负载均衡可以基于不同的协议,除了根据IP加端口进行负载外,还可根据七层的URL、浏览器类别、语言来决定是否要进行负载均衡

  • 选型

QPS十万以内,使用了Nginx作为唯一的负载均衡服务器,减少系统复杂度和维护成本

流量更大时,LVS 适合在入口处,承担大流量的请求分发; Nginx 要部署在业务服务器之前做更细维度的请求分发和故障节点检测

5.6.1.2 客户端负载均衡服务

LVS和Nginx适用于普通对的Web服务,不适用于微服务架构:

  1. 微服务架构服务节点存在注册中心,使用LVS很难和注册中心交互
  2. 微服务架构使用RPC协议而不是HTTP协议,Nginx不能满足要求

微服务架构可以将把负载均衡的服务内嵌在 RPC 客户端中,使用客户端负载均衡服务

注册中心提供服务节点的完整列表,客户端拿到列表之后使用负载均衡服务的策略选取一个合适的节点,然后将请求发到这个节点上

5.6.2 负载均衡策略

负载均衡策略从大体上来看可以分为两类:

5.6.2.1 静态策略

一类是静态策略,也就是说负载均衡服务器在选择服务节点时,不会参考后端服务的实际运行的状态

如轮询,IP和URL哈希

5.6.2.2 动态策略

一类是动态策略,也就是说负载均衡服务器会依据后端服务的一些负载特性,来决定要选择哪一个服务节点

5.6.3 使用Nginx进行负载均衡

5.6.3.1 负载均衡配置

5.6.3.2 感知节点故障

nginx模块nginx_upstream_check_module,定期地探测后端服务的一个指定的接口,然后根据返回的状态码,来判断服务是否还存活。当探测不存活的次数达到一定阈值时,就自动将这个后端服务从负载均衡服务器中摘除

5.6.3.3 感知节点新增节点

有一种 consul + nginx 方案,就是把节点信息写在 consul 里面,这样当节点变化时,nginx 可以得到通知

5.7 API网关

5.7.1 API网关的作用

API 网关(API Gateway)是一种架构模式,将一些服务共有的功能整合在一起,独立部署为单独的一层,用来解决一些服务治理的问题。你可以把它看作系统的边界,它可以对出入系统的流量做统一的管控

API 网关可以分为两类:一类叫做入口网关,一类叫做出口网关。

5.7.1.1 入口网关

部署在负载均衡服务器和应用服务器之间:

  1. 它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作
  2. 植入一些服务治理的策略
  3. 客户端的认证和授权的实现
  4. 做一些与黑白名单相关的事情
  5. 做一些日志记录的事情

5.7.1.2 出口网关

出口网关,对调用外部的API做统一的认证、授权、审计以及访问控制

5.7.2 TODO

5.8 多机房部署

5.8.1 部署方案

两个机房 A 和 B 都部署了应用服务,数据库的主库和从库部署在 A 机房,那么机房 B 的应用如何访问到数据呢? 有两种思路

  1. 一个思路是直接跨机房读取 A 机房的从库
  2. 机房B部署一个从库,跨机房同步主库数据,然后机房B读取同机房从库数据

5.8.2 数据传输时延迟

假设接口TP99要求是200ms

  1. 北京同城双机房专线,延迟1-3ms,接口读取缓存和数据库的数量少于10次可以接受
  2. 国内异地双机房专线,延迟50ms以内,尽量减少跨机房服务调用、数据库和缓存操作
  3. 国内访问美国西海岸服务,延时100-200ms,避免跨甲方同步调用,只能做异步的数据同步

5.8.3 业务方案选型

5.8.3.1 同城双活

主库部署在A,A和B都部署从库,一旦A机房发生故障,通过主从切换将B机房的从库提升为主库

不同机房的服务向注册中心注册不同的服务组,尽量避免跨机房的RPC调用,但是还是会存在跨机房写数据库的问题

缓存部署在两个机房中,查询请求只读取本机房的缓存

5.8.3.2 异地多活

随着业务不断发展,就要考虑所在城市发生重大灾害,也要保证系统可用性,需要采用异地多活方案

异地不能离的太近,否则自然灾害可能同时波及,这就造成更高的数据传输延迟,同城双活的跨机房写数据库方案便不再合适

数据同步方案两种方案相结合:

  1. 基于存储系统的主从复制,如MySQL和Redis
  2. 基于消息队列的方式,B机房产生的写入请求写入消息队列,A机房消费后执行业务逻辑和数据写入

5.9 ServiceMesh

将服务治理的细节,从 RPC 客户端中拆分出来,形成一个代理层单独部署。这个代理层可以使用单一的语言实现,所有的流量都经过代理层,来使用其中的服务治理策略。这是一种 关注点分离 的实现方式,也是 Service Mesh 的核心思想

5.9.1 sidecar

在应用程序同主机上部署一个代理程序Sidecar,RPC 客户端将数据包先发送给,与自身同主机部署的 Sidecar,在 Sidecar 中经过服务发现、负载均衡、服务路由、流量控制之后,再将数据发往指定服务节点的 Sidecar,在服务节点的 Sidecar 中,经过记录访问日志、记录分布式追踪日志、限流之后,再将数据发送给 RPC 服务端

5.9.2 Istio

业界提及最多的 Service Mesh 方案当属 istio,将组件分为 数据平面 和 控制平面:

  1. 数据平面即sidecar
  2. 控制平面主要负责服务治理策略的执行

六、维护

6.1 服务端监控

6.1.1 监控指标

延迟,吞吐量、错误和使用率

6.1.2 如何采集

6.1.2.1 Agent

Agent 主要收集的是组件服务端的信息,比如用agent链接memcached服务器,并且发送一个stats命令,获取服务器的统计信息

6.1.2.2 代码埋点

每个10s对资源的数据进行汇总,发送给监控服务器

6.1.2.3 日志

通过日志采集工具,将Tomcat、Nginx等日志中的数据发送给监控服务器

6.1.3 监控数据处理和存储

一般会先用消息队列来承接数据,主要的作用是削峰填谷,防止写入过多的监控数据,让监控服务产生影响。一般会部署两个队列处理程序,来消费消息队列中的数据

6.1.3.1 ES

将数据写入ES,通过Kibana展示原始数据,支持全文搜索

6.1.3.2 流式处理中间件

使用spark、strom等,从消息队列里接收数据并做一些处理:

  • 解析数据格式

  • 聚合运算

比如针对Tomcat访问日志,可以计算同一个URL一断时间内的请求量、响应时间和错误

  • 数据存储到时间序列数据库

常用的时序数据库有 InfluxDB、OpenTSDB、Graphite

  • Grafana链接时序数据库,绘制报表

6.2 性能管理

应用性能管理(Application Performance Management,简称 APM)。服务端监控的核心关注点是后端服务的性能和可用性,而应用性能管理的核心关注点是终端用户的使用体验,也就是你需要衡量,从客户端请求发出开始,到响应数据返回到客户端为止,这个端到端的整体链路上的性能情况。

6.2.1 如何搭建APM系统

  • 数据采集:客户单sdk采集信息并并定期上报
  • 数据存储:类似工作中遇到的客户端埋点格式
  • 数据展示:与服务端监控一样

6.2.2 如何采取网络数据

安卓一般会使用 OkHttpClient 来请求接口数据,而 OkHttpClient 提供了 EventListner 接口,可以让调用者接收到网络请求事件,可以得出一次请求过程中,经历的一系列过程的时间,其中主要包括下面几项:

  • 等待时间:请求会首先缓存在本地的队列里面,由专门的 I/O 线程负责,那么在 I/O 线程真正处理请求之前,会有一个等待的时间
  • DNS 时间
  • 握手时间
  • SSL 时间
  • 发送时间
  • 首包时间
  • 包接收时间

6.3 压力测试

如何搭建全链路压测平台

全链路压测平台需要包含以下几个模块:

  • 流量构造和产生模块
  • 压测数据隔离模块
  • 系统健康度检查和压测流量干预模块

6.3.1 压测数据产生

入口流量是来自于客户端的 HTTP 请求,拷贝一份存储在像是 HBase、MongoDB 这些 NoSQL 存储组件,或者亚马逊 S3 这些云存储服务中,我们称之为 流量数据工厂

拷贝方式:

  • 直接拷贝负载均衡服务器的访问日志,数据就以文本的方式写入到流量数据工厂中
  • 流量拷贝工具 GoReplay,它可以劫持本机某一个端口的流量,将它们记录在文件中

需要对 压测流量染色,也就是增加压测标记。在实际项目中,我会在 HTTP 的请求头中增加一个标记项,比如说叫做 is stress test

6.3.2 数据如何隔离

6.3.2.1 读取请求(下行流量)

对压测产生的用户行为,做特殊处理,不再记录到大数据日志中

一些推荐服务,展示过的商品就不再会被推荐出来,需要Mock 这些推荐服务

6.3.2.2 写入请求(上行流量)

把压测流量产生的数据,写入到影子库,针对不同的存储类型,我们会使用不同的影子库的搭建方式:

  • MySQL:同一个MySQL不同的Schema中创建一套和线上相同的库表结构,并导入线上数据
  • Redis:对压测流量产生的数据,增加一个统一前缀,存储在相同实例中
  • ES:放在另一个索引表

6.3.3 压力测试如何实施

而是按照一定的步长,逐渐地增加流量。在增加一次流量之后,让系统稳定运行一段时间,观察系统在性能上的表现。如果发现依赖的服务或者组件出现了瓶颈,回退到上一次压测的 QPS,保证服务的稳定,再针对此服务或者组件进行扩容,然后再继续增加流量压测。

6.4 配置管理

6.4.1 配置管理的方式

管理配置的方式主要有两种:

  • 一种是通过配置文件来管理;基础组件如Tomcat、Nginx都用这种方式管理配置项

  • 另一种是使用配置中心来管理;

6.4.2 配置中心如何实现

6.4.2.1 配置信息如何存储

Etcd 作为存储组件,支持存储全局配置、机房配置和节点配置。其中,节点配置优先级高于机房配置,机房配置优先级高于全局配置。也就是说,我们会优先读取节点的配置,如果节点配置不存在,再读取机房配置,最后读取全局配置

6.4.2.2 变更推送如何实现

  • 一种是轮询查询的方式;使用MD5,先轮询MD5,不一致再拉取最新配置
  • 一种是长连推送的方式;配置中心服务端保存每个连接关注的配置项列表,配置中心感知到配置变化后,就可以通过这个连接,把变更的配置推送给客户端

6.4.2.3 如何保证配置中心高可用

核心是配置中心「旁路化」,配置中心宕机不影响启动

我们一般会在配置中心的客户端上,增加两级缓存:第一级缓存是内存的缓存;另外一级缓存是文件的缓存

客户端会同时把配置信息同步地写入到内存缓存,异步地写入到文件缓存中

内存缓存的作用是降低客户端和配置中心的交互频率,文件的缓存的作用就是灾备

6.5 降级熔断

性能问题和隐患主要归结为两大类:

  • 依赖的资源或者服务不可用,最终导致整体服务宕机。解决思路是「降级」和「熔断」
  • 当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。解决思路是「限流」

6.5.1 雪崩是如何发生的

某一个服务或者组件宕机也许只会影响系统的部分功能,但它响应一慢,就会出现雪崩拖垮整个系统

解决的思路就是在检测到某一个服务的响应时间出现异常时,切断调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源

6.5.2 熔断机制是如何做的

断路器模式,服务调用方为每个调用的服务维护一个有限状态机,在关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)状态间相互转换

6.5.3 降级机制要如何做

特指开关降级,熔断和限流其实也是降级的一种

代码中预先埋设一些开关,写入到配置中心中

设计开关降级预案的时候,首先要区分哪些是核心服务,哪些是非核心服务 。因为我们只能针对非核心服务来做降级处理

实现策略有:

  • 读场景:直接返回降级数据,比如缓存替代数据库数据
  • 轮询场景:问降低获取数据的频率;
  • 写场景:同步写转换成异步写

6.6 流量控制

6.6.1 固定窗口算法

记录每秒钟访问次数,启动一个定时器定期重置计数

无法处理临界时间点上突发流量无法控制的问题

6.6.2 滑动窗口

将时间的窗口划分为多个小窗口,每个小窗口中都有单独的请求计数,计算区间的总请求量对比阈值

要存储每个小的时间窗口内的计数,所以空间复杂度有所增加

6.6.3 漏桶算法

流量会进入和暂存到漏桶里面,而漏桶的出口处会按照一个固定的速率将流量漏出到接收端,流出的流量都会变得比较平滑

一般会使用消息队列作为漏桶的实现,流量首先被放入到消息队列中排队,由固定的几个队列处理程序来消费流量,如果消息队列中的流量溢出,那么后续的流量就会被拒绝

6.6.4 令牌桶算法

算法原理:一秒内限制访问次数为 N 次,那么就每隔 1/N 的时间,往桶内放入一个令牌,处理请求之前先要从桶中获得一个令牌,如果桶中已经没有了令牌,那么就需要等待新的令牌或者直接拒绝服务

一般会使用 Redis 来存储这个令牌的数量。这样的话,每次请求的时候都需要请求一次 Redis 来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。 因此,一个折中的思路是: 我们可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数

七、实战篇

7.1 微博计数系统设计

haha

7.1.1 业务特点

  • 数据量巨大
  • 访问量巨大,性能要求高
  • 可用性、数字准确性要求高

7.1.2 高并发如何设计

7.1.2.1 MySQL方案

本着 KISS(Keep It Simple and Stupid)原则,流量不大的时候使用 MySQL 存储计数的数据

MySQL 数据库单表的存储量级达到几千万的时候,性能上就会有损耗,考虑使用分库分表的方式分散数据量,提升写计数的性能

数据库已经完全不能承担如此高的并发量,使用数据库主从结构,结合Redis加速读请求

7.1.2.2 Redis方案

数据库+缓存的方式有一个弊端:无法保证数据一致性,可以直接使用Redis作为计数的存储组件

可以通过批量处理消息的方式进一步减小 Redis 的写压力,使用消息队列来削峰填谷

7.1.3 降低存储成本

如何在有限的存储成本下实现对于全量计数数据的存取

7.1.3.1 原生数据结构改造

Redis 在存储 Key 时是按照字符串类型来存储的,比如一个 8 字节的 Long 类型的数据,需要8(sdshdr数据结构)+19(0字节数字长度)+ 1(\0) = 28 字节,改成使用Long类型存储只需要8字节,节省20字节

去除Redis中斗鱼指针,存储一个KV只需要8+4=12字节

7.1.3.2 优化内存使用

微博计数的数据具有明显的热点属性,为了尽量减少服务器的使用,我们考虑给计数服务增加 SSD 磁盘,然后将时间上比较久远的数据 dump 到磁盘上

读取冷数据的时候,使用单独的 I/O 线程异步地将冷数据从 SSD 磁盘中加载到一块儿单独的 Cold Cache(冷缓存) 中

7.2 未读数系统设计

7.2.1 系统通知的未读数设计

未读数:系统通知存储在大列表中,对所有用户共享。每个人根据看过的最后一条消息ID,计算大列表中这个ID后面有多少消息

小红点:为每一个用户存储一个时间戳,代表最近点过这个红点的时间。也记录一个全局的时间戳,只需要判断用户的时间戳和全局时间戳的大小

7.2.2 信息流未读数设计

7.2.2.1 复杂原因

信息流的未读数之所以复杂主要有这样几点原因:

  • 微博的信息流是基于关注关系的,未读数也是基于关注关系的,如果处理大V的关注关系是个问题
  • 信息流未读数请求量极大、并发极高,这是因为接口是客户端轮询请求的
  • 不像系统通知那样有共享的存储,因为每个人的关注点不同,信息流的列表不同

7.2.2.2 设计方案

承接每秒几十万次请求的信息流未读数系统:

  • 通用计数器中记录每一个用户发布的博文数
  • Redis 或者 Memcached 中记录一个人所有关注人的博文数快照
  • 当用户点击未读消息重置未读数为 0 时,将他关注所有人的博文数刷新到快照中
  • 关注所有人的博文总数减去快照中的博文总数就是他的信息流未读数

方案的缺陷,都是可以接受的:

  • 关注关系变更的时候更新不及时,那么就会造成未读数不准确
  • 全缓存存储,缓存满了可以剔除一部分数据

7.2.2.3 案例启发

  • 缓存是提升系统性能和抵抗大并发量的神器,微博团队仅仅用 16 台普通的服务器就支撑了每秒接近 50 万次的请求
  • 围绕系统设计的关键困难点想解决办法
  • 分析业务场景,明确哪些是可以权衡的;比如对于长久不登录用户,我们就会记录未读数为 0

7.3 通用信息流设计

参考文档

7.3.1 业务特点

业务关注点:

  • 关注延迟数据,信息更新速度
  • 如何支撑高并发的访问
  • 信息流拉取性能直接影响用户的使用体验

一般来说有两个思路:一个是基于推模式,另一个是基于拉模式

7.3.2 推模式

7.3.2.1 设计思路

给每一个用户都维护一份发件箱和收件箱,新消息写入发件箱消息队列,然后往所有关注着收件箱写消息,执行扩散往

7.3.2.2 存在问题

可以定期地清理数据,比如只保留最近 1 个月的数据

问题

推模式会遇到扩展性的问题,比如引入了分组,那么新微博要发送到同一个用户的多个收件箱

7.3.3 拉模式

7.3.3.1 设计思路

7.3.3.2 存在问题

需要对多个发件箱的数据做聚合,这个查询和聚合的成本比较高

缓存节点的带宽成本比较高

7.3.4 推拉结合