参考资料链接:高并发系统设计40问 系统设计题精选
书籍豆瓣链接:
开始学习时间:
预计完成时间:
实际完成时间:
一、基础篇
1.1 通用设计方法
高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。通用方法:Scale-up、Scale-out、缓存和异步
1.2 为何需要架构分层
业务越来越复杂,大量的代码纠缠在一起,会出现逻辑不清晰、各模块相互依赖、代码扩展性差、改动一处就牵一发而动全身等问题
1.2.1 什么是分层架构
「MVC」(Model-View-Controller)架构,将系统分成Model(模型),View(视图)和 Controller(控制器)三个层次,将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了 表现和逻辑的解耦
另外一种常见的分层方式是将整体架构分为表现层、逻辑层和数据访问层,通常会建立三个目录:Web、Service 和 Dao:
- 表现层,顾名思义嘛,就是展示数据结果和接受用户指令的,是最靠近用户的一层;
- 逻辑层里面有复杂业务的具体实现;
- 数据访问层则是主要处理和存储之间的交互。
OSI 网络模型,它把整个网络分了七层,TCP/IP 协议,它把网络简化成了四层。隔离关注点,让不同的层专注做不同的事情。
Linux 文件系统也是分层设计的,某些层次负责的是对下层不同实现的抽象,从而对上层屏蔽实现细节:
- 最上层是 虚拟文件系统(VFS),用来屏蔽不同的文件系统之间的差异,提供统一的系统调用接口
- 虚拟文件系统的下层是 Ext3、Ext4 等各种文件系统
- 再向下是为了屏蔽不同硬件设备的实现细节,我们抽象出来的单独的一层——通用块设备层
- 然后就是不同类型的磁盘了
1.2.2 分层的好处
- 简化系统设计,让不同的人专注做某一层次的事情
- 分层之后可以做到很高的复用
- 分层架构可以让我们更容易做横向扩展。比如说,业务逻辑里面包含有比较复杂的计算,导致 CPU 成为性能的瓶颈,那这样就可以把逻辑层单独抽取出来独立部署
1.2.3 分层架构的不足
- 最主要的一个缺陷就是增加了代码的复杂度
- 多层的架构在性能上会有损耗
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的可扩展性分为下面三个特性:
- Replica,对应主从分离
- Shard,对应分片
- 负载均衡,减少了数据迁移和验证成本
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:
- 一种做法是write allocate,将数据库的数据导到缓存,再更新数据
- 另一种做法是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 静态资源缓存机制
缓存可以分为客户端缓存和服务端缓存:
- 客户端缓存指的是浏览器缓存, 浏览器缓存是最快的缓存, 因为它直接从本地获取(但有可能需要发送一个协商缓存的请求), 它的优势是可以减少网络流量, 加快请求速度
- 服务端缓存指的是反向代理服务器或CDN的缓存, 他的作用是用于减轻后端实际的Web Server的压力
3.5.1 客户端缓存
3.5.1.1 强制缓存
浏览器在加载资源的时候,会先根据本地缓存资源的header中的信息(Expires 和 Cache-Control)来判断缓存是否过期。如果缓存没有过期,则会直接使用缓存中的资源;否则,会向服务端发起协商缓存的请求
3.5.1.2 协商缓存
第一次请求静态资源,响应头有Etag字段,浏览器缓存这个字段。下次请求头里会有一个If-None-Match字段,并把Etag发给客户端。如果图片信息没有变化,则返回的304 Not Modified
3.5.2 服务端缓存
proxy cache属于服务端缓存,主要实现 nginx 服务器对客户端数据请求的快速响应。 nginx 服务器在接收到被代理服务器的响应数据之后,一方面将数据传递给客户端,另一方面根据proxy cache的配置将这些数据缓存到本地硬盘上。 当客户端再次访问相同的数据时,nginx服务器直接从硬盘检索到相应的数据返回给用户,从而减少与被代理服务器交互的时间
3.6 静态资源如何加速
将静态资源从,静态资源服务器比如Nginx,迁移到CDN上,支持更大请求量和带宽,访问速度更快
搭建一个 CDN 系统需要考虑哪两点:
- 如何将用户的请求映射到 CDN 节点上
- 如何根据用户的地理位置信息选择到比较近的节点
3.5.1 用户请求映射
DNS(Domain Name System,域名系统)实际上就是一个存储域名和 IP 地址对应关系的分布式数据库。域名解析的结果一般有两种:
- A记录,返回域名对应IP
- CNAME记录,返回另一个域名
将静态资源的域名的解析结果的CNAME配置到CDN提供的DNS域名上,CDN提供的DNS充当一个中间代理层的角色,可以把将用户最初使用的域名代理到正确的 IP 地址上,即可实现映射
3.5.2 DNS解析优化
3.5.2.1 域名解析过程
www.baidu.com
域名解析过程:
- 检查本机的 hosts 文件,查看是否有 www.baidu.com 对应的 IP
- 没有的话,就请求 Local DNS 是否有域名解析结果的缓存,如果有就返回,标识是从非权威 DNS 返回的结果
- 如果没有,先请求根 DNS,根 DNS 返回顶级 DNS(.com)的地址
- 再请求 .com 顶级 DNS,得到 baidu.com 的域名服务器地址
- 再从 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 消费端
-
提升consumer代码性能
-
一个partition对应一个consumer,否则需要加锁。consumer创建一个线程池,一次拉取多条消息,分配给多个线程处理
-
客户端拉取消息,采用递增步长,10ms拉不到,下次20ms,20ms拉不到下次100ms
4.4.2.2 消息队列
-
数据库写入qps只能到千级别,消息存储使用本地磁盘而不是数据库,page cache可以提升读取速度,因为消息读取是顺序的
-
零拷贝,sendfile直接将数据从内核缓冲区拷贝到socket缓冲区,不经过用户缓冲区