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

Posted by 小石匠 on 2022-04-15

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

书籍豆瓣链接:

开始学习时间:

预计完成时间:

实际完成时间:

一、基础篇

1.1 通用设计方法

高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。通用方法:Scale-up、Scale-out、缓存和异步

1.2 为何需要架构分层

业务越来越复杂,大量的代码纠缠在一起,会出现逻辑不清晰、各模块相互依赖、代码扩展性差、改动一处就牵一发而动全身等问题

1.2.1 什么是分层架构

「MVC」(Model-View-Controller)架构,将系统分成Model(模型),View(视图)和 Controller(控制器)三个层次,将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了 表现和逻辑的解耦

另外一种常见的分层方式是将整体架构分为表现层、逻辑层和数据访问层,通常会建立三个目录:Web、Service 和 Dao:

  1. 表现层,顾名思义嘛,就是展示数据结果和接受用户指令的,是最靠近用户的一层;
  2. 逻辑层里面有复杂业务的具体实现;
  3. 数据访问层则是主要处理和存储之间的交互。

OSI 网络模型,它把整个网络分了七层,TCP/IP 协议,它把网络简化成了四层。隔离关注点,让不同的层专注做不同的事情。

Linux 文件系统也是分层设计的,某些层次负责的是对下层不同实现的抽象,从而对上层屏蔽实现细节:

  1. 最上层是 虚拟文件系统(VFS),用来屏蔽不同的文件系统之间的差异,提供统一的系统调用接口
  2. 虚拟文件系统的下层是 Ext3、Ext4 等各种文件系统
  3. 再向下是为了屏蔽不同硬件设备的实现细节,我们抽象出来的单独的一层——通用块设备层
  4. 然后就是不同类型的磁盘了

1.2.2 分层的好处

  1. 简化系统设计,让不同的人专注做某一层次的事情
  2. 分层之后可以做到很高的复用
  3. 分层架构可以让我们更容易做横向扩展。比如说,业务逻辑里面包含有比较复杂的计算,导致 CPU 成为性能的瓶颈,那这样就可以把逻辑层单独抽取出来独立部署

1.2.3 分层架构的不足

  1. 最主要的一个缺陷就是增加了代码的复杂度
  2. 多层的架构在性能上会有损耗

1.3 高并发系统设计目标

1.3.1 高并发系统设计三大目标

  • 高性能:
  • 高可用:
  • 可扩展性:处理峰值流量,弹性扩容

1.3.2 性能优化准则

  • 问题导向:不能盲目提早优化,
  • 二八法则:20%精力解决80%性能问题,优化主要的性能瓶颈点
  • 数据支撑:需要量化,耗时减少多少,吞吐量提高多少

1.3.3 如何做到高性能

1.3.3.1 提高并行处理能力

增加系统的并行处理能力, 吞吐量 = 并发进程数 / 响应时间,并行化加速比 (Ws + Wp) / (Ws + Wp/s),Ws表示串行计算量,Wp表示并行计算量,s表示并行进程数1/(1-p+p/s)

1.3.3.2 减少任务响应时间

  • CPU密集型:优化算法
  • IO密集型:性能分析工具和监控

1.3.4 如何做到高可用

可用性指标分故障平均间隔时间故障平均恢复时间

1.3.5 如何做到可扩展

1.3.5.1 系统设计

  • 故障转移:

分对等节点:节点都承担读写流量,节点中不保存状态

非对等节点:热备、冷备和温备。故障检测常用机制是「心跳」,选主算法Paxos,Raft

  • 调用超时控制

  • 降级:是为了保证核心服务的稳定而牺牲非核心服务的做法

  • 限流:

1.3.5.2 系统运维

灰度发布、故障演练

1.3.6 如何让系统易于扩展

1.3.6.1 存储层的扩展性

按业务垂直拆分,水平分库分表,尽量不要使用分布式事务

1.3.6.2 业务层的扩展性

三个维度考虑业务层的拆分方案,它们分别是:

  • 业务纬度

  • 重要性纬度

  • 请求来源纬度

二、数据库

2.1 池化技术

减少频繁创建数据库链接的性能损耗,使用池化技术

2.1.1 用连接池预先建立数据库连接

2.1.2 用线程池预先创建线程

ThreadPoolExecutor 线程池

2.2 主从分离

单机运行mysql,大概可以支撑千级别的TPS万级别的QPS

大部分系统的访问模型是读多写少,采用主从分离技术抗住更高的查询请求

主从读写分离有两个技术上的关键点:

  • 数据的拷贝,主从复制
  • 屏蔽主从分离带来的访问数据库方式的变化

2.2.1 主从复制

2.2.1.1 流程原理

  • 主节点

主节点创建log dump现成发送binlog给从库

  • 从库

从库创建IO线程,请求主库更新的binlog,并写入relay log的日志文件中

从库创建创建一个SQL现成读取relay log,做回放

  • 从库数量

对于每个从库,主库需要创建同样多的log dump现成来发送binlog,且受限于主库网络带宽。实际使用中,一般一个主库最多挂3-5个从库

2.2.1.2 处理主从延迟

  • 数据冗余

避免从数据库中重新查询数据。

足够简单优先考虑,会造成传输数据体较大

  • 使用缓存

写数据库的时候,同步写缓存。

适合新增数据的场景,更新数据的场景会造成数据不一致

  • 查询主库

一般不使用这个方案

2.2.2 如何访问数据库

使用数据库中间件

2.3 分库分表

数据库的写入请求量大造成的性能和可用性方面的问题,对数据进行分片,突破单机的容量和请求量的瓶颈

2.3.1 拆分方式

2.3.1.1 垂直拆分

业务相关性

2.3.1.2 水平拆分

哈希值拆分;区间拆分,如时间

2.3.1 引入问题

join和count查询无法实现,需要异构出一张宽表,或者使用分布式缓存

单库单表到分库分表改造,分库分表扩容,都是非常麻烦的。性能没有瓶颈尽量不分库分表,要分就一次到位,比如16库64表基本能满足几年内你的业务需求

NoSQL数据库,如Hbase和MongoDB都提供了auto sharding的特性,可以考虑替代关系型数据库

2.4 高并发场景下NoSQL和数据库的互补

NoSQL指的是不同于传统关系型数据库的数据库的统称,有下列分类:

kv存储数据库:Redis,LevelDb,相比传统数据库的优势是更高的读写性能

列式存储数据库:Hbase,Cassandra,按列来存储,适用于一些离线数据统计的场景

文档型数据库:MongoDB,CouchDB,特点是Schema Free

2.4.1 使用NoSQL提升写入性能

以 MySQL 的 InnoDB 存储引擎来说,更新 binlog、redolog、undolog 都是在做顺序 IO,而更新 datafile 和索引文件则是在做随机 IO

很多NoSQL使用了基于LSM的存储引擎,LSM树(Log-structure Merge Tree)牺牲了一定的读性能换取写入数据的高性能,比如LevelDb、Hbase、Cassandra

2.4.2 场景补充

传统数据库只能使用索引的最左前缀匹配,不支持全文搜索。全文搜索可以使用基于倒排索引作为核心技术原理的ElasticSearch

2.4.3 提升扩展性

NoSQL 数据库天生支持分布式,支持数据冗余和数据分片的特性,比如mongoDB的可扩展性分为下面三个特性:

  1. Replica,对应主从分离
  2. Shard,对应分片
  3. 负载均衡,减少了数据迁移和验证成本

2.4.4 使用NoSQL的注意点

NoSQL 可供选型的种类很多,每一个组件都有各自的特点。你在做选型的时候需要对它的实现原理有比较深入的了解,最好在运维方面对它有一定的熟悉,这样在出现问题时才能及时找到解决方案。 否则,盲目跟从地上了一个新的 NoSQL 数据库,最终可能导致会出了故障无法解决,反而成为整体系统的拖累。

三、缓存

3.1 使用缓存解决数据库IO瓶颈

做一次内存寻址大概需要 100ns,而做一次磁盘的查找则需要 10ms,缓存作为一种常见的 空间换时间的性能优化手段

3.1.1 缓存案例

MMU通过TLB缓存虚拟地址与物理地址的映射

feed流预加载,或者降级兜底缓存

HTTP缓存等

3.1.2 缓存与缓冲区区别

位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存

缓冲区:缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。缓冲区更像是消息队列,用以弥补高速设备和低速设备通信时的速度差

3.1.3 缓存分类

静态缓存:静态HTML页面

分布式缓存:Memcache、Redis

热点本地缓存:HashMap、Guava Cache、Ehcache。遇到极端的热点数据查询的时候,比如跑马灯,电商首页

3.1.4 缓存的不足

缓存更适用于读多写少的业务场景,并且数据最好带有一定的热点属性

缓存会给整体系统带来复杂度,会有数据不一致风险

缓存通常使用内存作为存储介质,成本较高不是无限制的

缓存会给运维带来一定的成本

3.2 如何选择缓存读写策略

3.2.1 Cache Aside(旁路缓存)策略

同时更新缓存和数据库

3.2.1.1 读写策略

读策略:读更新缓存

写策略:更新并删除缓存

3.2.1.2 存在问题

  • 问题一:请求B的写更新,介于请求A的读更新之中,最终缓存中加载的还是旧数据

  • 问题二:或者插入新数据后,因为数据库主从延迟读不到

  • 问题三:对缓存命中率也有影响

3.2.1.3 策略优化

更新数据的同时更新缓存,更新缓存前加分布式锁,对写入性能会有一定的影响

缓存过期时间缩短,即使出现不一致,缓存的数据也可以很快的过期

3.2.2 Read/Write Through 读穿/写穿策略

这个策略的核心原则是用户只与缓存打交道,先更新缓存,缓存负责同步更新数据库

3.2.2.1 写策略

查询缓存中数据是否存在,如果write miss:

  1. 一种做法是write allocate,将数据库的数据导到缓存,再更新数据
  2. 另一种做法是no-write allocate,不写入缓存,直接更新到数据库

一般使用no-write,提升写入性能

3.2.2.2 读策略

缓存中没有,从数据库加载到缓存

3.2.2.3 使用场景

用户只与缓存节点交互,redis和memcached不提供该功能,使用本地缓存可以考虑,比如Guava Cache中的Loading Cache

3.2.3 Write Back 写回策略

先更新缓存,缓存定时异步更新数据库

写入数据只写入缓存,并且将缓存标记为脏,计算机体系结构中的策略,异步将内存脏页刷回磁盘

3.3 缓存如何做到高可用

3.3.1 客户端方案

  • 写缓存分片

一致性哈希,为了提高平衡性,使用虚拟节点

一致性哈希,映射会发生改变,一定要设置缓存过期时间

  • 客户端主从

memcached的主从机制是在客户端实现的

  • 多副本

3.3.2 中间代理层方案

缓存的 读写请求 都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑

3.3.3 服务端方案

redis sentinel是在服务端提供的一种高可用方案

3.4 缓存穿透了怎么办

对于缓存来说,命中率是它的生命线。缓存的容量有限,只需要在有限的缓存空间里存储 20% 的热点数据,放弃缓存另外 80% 的非热点数据,少量的缓存穿透是不可避免的

常用的防止大量请求缓存穿透的方案:回种空值、布隆过滤器

3.4.1 回种空值

当我们从数据库中查询到空值或者发生异常时,可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,会给这个空值加一个比较短的过期时间。使用的时候应该评估一下缓存容量是否能够支撑,大量的空值缓存

3.4.2 布隆过滤器

由一个二进制数组和Hash算法组成,新创建的数据ID哈希到布隆过滤器中,查找的时候判断数据ID的哈希值在布隆过滤器中的取值,为0则返回空

  • 优点

由于使用哈希,拥有极高的性能,时间复杂度O(1)。20亿数据只需要不到1G空间

  • 缺陷

存在false positive的情况

不支持删除

  • 改进

使用多个哈希算法,减少误判

存储数字而不是0或1,标识命中次数,这样可以支持删除

3.4.3 狗桩效应

某个极热点缓存项,一旦失效会有大量请求穿透到数据库,解决方法是当缓存未命中,使用memcached或者redis设置分布式锁,获取了锁才能穿透到数据库,将数据加载回缓存。其他未命中且无法获取锁的请求,直接返回

3.5 静态资源缓存机制

缓存可以分为客户端缓存和服务端缓存:

  1. 客户端缓存指的是浏览器缓存, 浏览器缓存是最快的缓存, 因为它直接从本地获取(但有可能需要发送一个协商缓存的请求), 它的优势是可以减少网络流量, 加快请求速度
  2. 服务端缓存指的是反向代理服务器或CDN的缓存, 他的作用是用于减轻后端实际的Web Server的压力

3.5.1 客户端缓存

NGINX缓存详解(一)之客户端缓存

3.5.1.1 强制缓存

浏览器在加载资源的时候,会先根据本地缓存资源的header中的信息(Expires 和 Cache-Control)来判断缓存是否过期。如果缓存没有过期,则会直接使用缓存中的资源;否则,会向服务端发起协商缓存的请求

3.5.1.2 协商缓存

第一次请求静态资源,响应头有Etag字段,浏览器缓存这个字段。下次请求头里会有一个If-None-Match字段,并把Etag发给客户端。如果图片信息没有变化,则返回的304 Not Modified

3.5.2 服务端缓存

NGINX缓存详解(二)之服务端缓存

proxy cache属于服务端缓存,主要实现 nginx 服务器对客户端数据请求的快速响应。 nginx 服务器在接收到被代理服务器的响应数据之后,一方面将数据传递给客户端,另一方面根据proxy cache的配置将这些数据缓存到本地硬盘上。 当客户端再次访问相同的数据时,nginx服务器直接从硬盘检索到相应的数据返回给用户,从而减少与被代理服务器交互的时间

3.6 静态资源如何加速

将静态资源从,静态资源服务器比如Nginx,迁移到CDN上,支持更大请求量和带宽,访问速度更快

搭建一个 CDN 系统需要考虑哪两点:

  1. 如何将用户的请求映射到 CDN 节点上
  2. 如何根据用户的地理位置信息选择到比较近的节点

3.5.1 用户请求映射

DNS(Domain Name System,域名系统)实际上就是一个存储域名和 IP 地址对应关系的分布式数据库。域名解析的结果一般有两种:

  1. A记录,返回域名对应IP
  2. CNAME记录,返回另一个域名

将静态资源的域名的解析结果的CNAME配置到CDN提供的DNS域名上,CDN提供的DNS充当一个中间代理层的角色,可以把将用户最初使用的域名代理到正确的 IP 地址上,即可实现映射

3.5.2 DNS解析优化

3.5.2.1 域名解析过程

www.baidu.com域名解析过程:

  1. 检查本机的 hosts 文件,查看是否有 www.baidu.com 对应的 IP
  2. 没有的话,就请求 Local DNS 是否有域名解析结果的缓存,如果有就返回,标识是从非权威 DNS 返回的结果
  3. 如果没有,先请求根 DNS,根 DNS 返回顶级 DNS(.com)的地址
  4. 再请求 .com 顶级 DNS,得到 baidu.com 的域名服务器地址
  5. 再从 baidu.com 的域名服务器中查询到 www.baidu.com 对应的 IP 地址,标记这个结果是来自于权威 DNS。同时写入 Local DNS 的解析结果缓存

3.5.2.2 域名解析优化

APP启动时进行预先解析,然后把解析结果缓存到LRU本地缓存,并定时更新缓存中数据

3.5.3 找到最近的CDN节点

CDN DNS返回GSLB(Global Server Load Balance,全局负载均衡)的域名

3.6 数据迁移应该如何做

3.6.1 如何平滑迁移数据

mysql主从同步可以做到准实时的数据拷贝,mysqldump工具也可以将数据导出,这两种方式只支持单库到单库的迁移

迁移过程需要满足以下几个目标:

  • 迁移应该是在线的迁移,也就是在迁移的同时还会有数据的写入
  • 数据应该保证完整性,迁移之后新库和旧库的数据保持一致
  • 迁移的过程需要做到可以回滚

3.6.1.1 双写方案

MySQL、Redis和消息队列都可以使用这种方式,分为以下几个步骤:

  • 将新的库配置为源库的从库,用来同步数据;如果需要将数据同步到多库多表,可以使用第三方工具获取 Binlog 的增量日志(比如开源工具 Canal)
  • 改造业务代码,完成双写。出于性能考虑,可以异步地写入新库,写入新库失败的数据记录在单独的日志中
  • 校验数据,最容易出问题的步骤就是数据校验的工作,提前写好校验工具并充分验证
  • 将读流量切换到新库,最好采用灰度的方式来切换
  • 可以将数据库的双写改造成只写新库

好处是: 迁移的过程可以随时回滚,将迁移的风险降到了最低。 劣势是: 时间周期比较长,应用有改造的成本

3.6.1.2 级联同步方案

比较适合MySQL和Redis数据从自建机房向云上迁移的场景,担心云上的环境和自建机房的环境不一致,通过级联同步的方式在自建机房留下一个可回滚的数据库

  • 先将新库配置为旧库的从库,用作数据同步
  • 将一个备库配置为新库的从库,用作数据的备份
  • 三个库的写入一致后,将数据库的读流量切换到新库
  • 暂停应用的写入,将业务的写入流量切换到新库
  • 如果要回滚,先将读流量切换到备库,再暂停应用的写入,将写流量切换到备库

优势是 简单易实施,缺点是 在切写的时候需要短暂的停止写入

3.6.2 数据迁移时如何预热缓存

从自建机房向云上迁移数据时,我们也需要考虑缓存的迁移方案,缓存迁移的重点是保持缓存的热度

Redis 的数据迁移可以使用双写的方案或者级联同步的方,这里以 Memcached 为例

3.6.2.1 使用副本预热缓存

可以在云上部署一个副本组,云上的应用服务器读取云上的副本组,如果副本组没有查询到数据,就可以从自建机房部署的主从缓存上加载数据,回种到云上的副本组上

这种方式足够简单,不过有一个致命的问题是: 如果云上的请求穿透云上的副本组,到达自建机房的主从缓存时,这个过程是需要跨越专线的。专线的延迟相比于缓存的读取时间是比较大的,即使是本地的不同机房之间的延迟也会达到 2ms~3ms,在实际项目中我们很少使用这种方案

3.6.2.2 改造副本组方案预热缓存

可以通过方案的设计在系统运行中自动完成缓存的预热:

  • 部署多组 mc 的副本组,自建机房在接收到写入请求时,会优先写入自建机房的缓存节点,异步写入云上部署的 mc 节点
  • 处理自建机房的读请求时,会指定一定的流量,比如 10%,优先走云上的缓存节点
  • 当云上缓存节点的命中率达到 90% 以上时,就可以在云上部署应用服务器

实现缓存数据的迁移,又可以尽量控制专线的带宽和请求的延迟情况

四、消息队列

4.1 如何处理大量写请求

异步处理、解耦合和削峰填谷 是消息队列在秒杀系统设计中起到的主要作用

4.2处理消息丢失

4.2.1 消息生产阶段

使用重传,重试次数2-3次,但仍然有丢失的风险

4.2.2 消息队列阶段

为减少磁盘的随机I/O,会将消息写入操作系统的Page Cache中,然后异步刷入磁盘,这样会造成未持久化宕机造成的消息丢失

redis集群有leader提供消息写入和消费,多个follower负责数据的备份,leader故障就从follower的ISR选主

如果需要确保消息不丢失,生产者需要设置acks=all,每一条消息必须得到所有Leader和ISR确认才会被认为发送成功

4.2.3 消费过程阶段

一定要等到消息接收和处理完成后才能更新消费进度,但是这也会造成消息重复的问题

4.3 如何保证消息只被消息一次

想要完全的避免消息重复的发生是很难做到的,只能保证在消息的生产和消费的过程是幂等的

4.3.1 消息不重复生产

给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储<生产者 ID,最后一条消息ID>的映射,丢失重复消息

4.3.2 消费端

通过版本号,发送消息查询版本号,消费后增加版本号,丢弃低版本号消息

4.4 如何降低消息队列系统消息的延迟

4.4.1 监控消息延迟

Kakfa 的一个专门的 topic 叫 __consumer_offsets,Kafka 安装包的 bin 目录下有个工具kafka-consumer-groups.sh,可以查看消息消费情况

4.4.2 优化消息延迟

4.4.2.1 消费端

  1. 提升consumer代码性能

  2. 一个partition对应一个consumer,否则需要加锁。consumer创建一个线程池,一次拉取多条消息,分配给多个线程处理

  3. 客户端拉取消息,采用递增步长,10ms拉不到,下次20ms,20ms拉不到下次100ms

4.4.2.2 消息队列

  1. 数据库写入qps只能到千级别,消息存储使用本地磁盘而不是数据库,page cache可以提升读取速度,因为消息读取是顺序的

  2. 零拷贝,sendfile直接将数据从内核缓冲区拷贝到socket缓冲区,不经过用户缓冲区