刘旭的个人网站

笔记内容主要来源于极客时间的《高并发系统设计40问》,有改动。

高并发系统设计三大目标

高性能

性能优化要以问题为导向,在合适的度量指标的参考下,持续地做性能优化。

常用的性能度量指标就是响应时间,相比平均响应时间和最大响应时间,分位响应时间是更合理的选择。90分位响应时间表示百分之90的请求在这个响应时间之内。

一般的,一个优秀系统的响应时间的99分位值应当控制在200ms以内,99.99%的响应时间应当在1s以内。

性能优化思路:

高可用

高可用指的是系统具备较高的无故障运行的能力。

如何度量可用性?

我们定义:

可用性 = MTBF / (MTBF + MTTR)

一般的,我们用几个九来描述可用性,比如,一个九指90%,两个九指99%,三个九指99.9%,四个九指99.99%,五个九指99.999%,…

一个九和两个九,人工运维基本就能达到。

三个九和四个九,需要建立完善的运维值班体系和故障处理流程。

五个九就要靠系统的容灾和自动恢复能力了。

一般的,核心业务系统的可用性应该达到四个九,非核心业务系统的可用性可以放宽到三个九。

故障转移(failover)

对等节点failover比较简单,某一个节点故障,随机选择其他节点就可以了。

主备节点failover比较复杂,需要做故障检测。一般通过心跳检测来做故障检测。主节点故障时触发选主操作,选主的结果需要在备份节点间达成一致,涉及分布式一致性算法。

超时控制

出现大面积延迟时,大量系统资源被占用,导致系统整体崩溃。超时控制就是要避免出现这种问题,当请求处理超过一定时间时,直接让请求失败,释放资源给其他请求。

超时控制的关键是超时时间的设定,超时时间过大或过小都不行,这个需要依据历史日志或经验来确定一个相对合理的超时时间,并根据实际情况做出调整。

限流、熔断、降级

限流指的是通过限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超出限制的流量,只能通过拒绝服务的方式来保证整体系统的可用性。

限流算法:

熔断指的是当发起服务调用的时候,如果返回错误或者超时的次数超过一定阈值,则后续的请求不再发往远程服务而是暂时返回错误。

熔断可以看作断路器模式的实现,在断路器模式下,服务调用方为每一个调用的服务维护一个有限状态机:

断路器模式状态机示意图

降级是为了保证核心服务的稳定而牺牲非核心服务的做法。广义上,降级是更抽象的概念,限流和熔断都可以看作是降级。狭义上,一般说到降级指的是开关降级,即通过在代码中预埋开关来控制业务逻辑。

灰度发布和故障演练

故障演练指的是对系统进行一些破坏性的手段,观察在出现局部故障时,整体的系统表现是怎样的。

易扩展

为什么不容易实现易扩展呢?因为扩展并不仅仅是业务服务器的扩展,还涉及到数据库、缓存、第三方服务等上下游全链路的各种服务。

横向扩展(Scale-out)

即分而治之,采用分布式集群的方式把流量分流开,让每个服务器承担一部分并发和流量。

与横向扩展对应的是纵向扩展(Scale-up),纵向扩展通过提高硬件配置来提升系统的并发处理能力。

比如,一个4核4G的系统现在能处理200QPS的流量,如果流量增大到400QPS呢?纵向扩展的思路是换一个8核8G性能更好的机器,横向扩展的思路是增加一台4核4G的机器组成一个集群来处理。

纵向扩展的问题在于受限于单机处理能力的极限。

横向扩展可以突破单机极限,但同时引入了一些复杂问题,比如:

分层

为什么要分层?

常见的分层设计:MVC模型,OSI七层模型,TCP/IP四层模型,Linux文件系统等。

如何进行分层设计?分层设计的关键是合理地界定不同层级的边界,当你觉得不同层级间逻辑混杂时,那可能就需要考虑增加新的层级了。

分层示例: 分层示例

异步

与异步相对的是同步,那么什么是同步,什么是异步呢?

与同步和异步容易混淆在一起的概念是阻塞和非阻塞。一种常见的误解是同步等价于阻塞,异步等价于非阻塞,但其实同步异步和阻塞非阻塞没有直接关系。

同步和异步描述的是通信机制(communication mechanism):

阻塞和非阻塞描述的是the status of the program while waiting for the result from the function call:

一个简单的例子说明同步异步、阻塞非阻塞概念,假设你打电话到一个酒店预定房间:

池化技术

池化技术的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的开销,同时可以对对象进行统一的管理,降低了对象的使用成本。

数据库连接池

两个重要参数:最小连接数和最大连接数。建议最小连接数控制在10左右,最大连接数控制在20~30左右。

连接过程:

  1. 如果当前连接数小于最小连接数,则创建新的连接处理请求
  2. 如果连接池中有空闲连接,则复用空闲连接
  3. 如果连接池中没有空闲连接,并且当前连接数小于最大连接数,则创建新的连接处理请求
  4. 如果当前连接数已经大于等于最大连接数,则按照设定的等待时间等待可用连接
  5. 如果等待超时,则抛出异常

连接池的管理维护: 最基本的问题就是如何保证连接池中的连接是有效的、可用的?一种方式是启动一个线程定期检测连接池中的连接是否可用。还有一种方式是获取到连接后先校验连接是否可用,这种方式在获取连接时引入了多余的开销,线上系统最好不要采取这种策略。

线程池

ThreadPoolExecutor是JDK 1.5引入的线程池实现。类似的,有两个重要参数,核心线程数和最大线程数。

连接过程:

  1. 如果线程池中的线程数小于核心线程数,则创建新的线程处理任务;
  2. 如果线程数大于核心线程数,则把任务丢到一个队列里,等待空闲的线程执行;
  3. 当队列中的任务堆积满了的时候,则继续创建线程,直到达到最大线程数;
  4. 当线程数达到最大线程数时,默认丢弃任务。

当线程数达到核心线程数时,JDK实现的线程池会把新任务放到一个等待队列里,而不是直接继续创建新线程。这种方式适用于CPU密集型任务,不适用于IO密集型任务。Tomcat使用的线程池就没有使用等待队列。

池化注意事项

MySQL

主从复制

将一个数据库的数据拷贝为多份,原始的数据库称为主库,主要负责数据的写入,拷贝的数据库称为从库,主要负责数据的查询。

Mysql主从复制流程:主库会创建一个log dump线程来发送binlog给从库。从库在连接到主库时会创建一个IO线程,用来请求主库更新的binlog,并且把接收到的binlog信息写入一个叫做relay log的日志文件中。同时,从库还会创建一个sql线程读取relay log,并且在从库中回放,实现主从复制。

MySQL主从同步

是不是可以无限增加从库呢?不是的,一般一个主库最多挂3~5个从库。

主从延迟

主从延迟带来的典型问题就是读从库查询不到信息,对于这种问题,可以通过消息、缓存等方式来处理,尽量不要读主库。

主从延迟时间应当作为重点监控指标,一般主从延迟是毫秒级的。

分库分表

分库分表是常用的数据分片方式之一。

将单一数据表根据某种规则拆分到多个数据库和多个数据表中,比如,根据id字段做哈希拆分、根据时间字段做区间拆分。

分库分表以后,任何操作都强依赖分区键。另外,不能做多表join、count操作也比较麻烦。

为什么需要发号器?因为数据库分库分表后,简单的使用自增id不能满足全局唯一性。

为什么不使用UUID?

Snowflake算法的核心思想是将64bit的二进制数字分成若干部分,每一部分存储有特定含义的数据,由此生成全局唯一的有序id:

Snowflake算法示意图

一般的,你可以根据需要调整Snowflake算法,定制自己的发号器实现。

数据迁移

级联迁移方案

级联迁移方案示意图

级联迁移方案回滚示意图

这种方案的优点是简单易实施,业务上基本没有改造的成本;缺点是切写的时候需要短暂地停止写入,业务上是有损的。

双写方案

双写方案示意图

这种方案的优点是业务无损,缺点是时间周期比较长,业务有改造成本。

NoSQL

NoSQL指的是不同于传统的关系型数据库的其他数据库系统的统称。

为什么需要NoSQL?

NoSQL可以作为关系型数据库的补充,弥补关系型数据库在某些场景下性能和扩展性的不足。

常见的NoSQL数据库

缓存

广义上讲,凡是位于不同速度存储设备之间,用于协调存取速度差异的,都可以称为缓存。另外,存储复杂计算的结果以避免重复计算也是一种缓存。

为什么使用缓存可以提高系统的并发处理能力呢?因为不同存储介质的访问速度差异非常大,缓存就是避免存取访问速度慢的存储介质。

可以参考Latency Numbers Every Programmer Should Know

缓存分类:

使用缓存的注意事项:

旁路缓存策略(Cache Aside)

读策略:

  1. 从缓存中读取数据;
  2. 如果命中缓存,则直接返回数据;
  3. 如果未命中缓存,则查询数据库;
  4. 将查询到的数据写入缓存。

写策略:

  1. 更新数据库中的记录;
  2. 删除缓存记录。

为什么写数据时删除缓存而不是更新缓存?因为更新缓存比较麻烦,既要处理并发问题,又要注意数据一致性问题。

旁路缓存策略最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,影响缓存命中率。

读穿/写穿策略(Read/Write Through)

读穿/写穿策略的核心是你只与缓存交互,由缓存和数据库交互。

读策略:

  1. 从缓存中读取数据;
  2. 如果命中缓存,则直接返回数据;
  3. 如果未命中缓存,则由缓存从数据库加载数据。

写策略:

  1. 查询要写入的数据在缓存中是否存在;
  2. 如果存在,则更新缓存,由缓存同步更新数据库;
  3. 如果不存在,那么可以选择(1)写缓存,由缓存同步更新数据库;(2)直接写数据库。

写回策略(Write Back)

写回策略的核心是写入数据时只写入缓存,并且把缓存块标记为“脏”,脏块只有被再次使用时才会将其中的数据写入到下一级存储中。Page Cache、异步刷盘等都是写回策略的应用。

读策略:

  1. 从缓存中读取数据;
  2. 如果命中缓存,则直接返回数据;
  3. 如果未命中缓存,则寻找可用缓存块,判断缓存块是否为脏。如果缓存块为脏,则将脏数据写入下一级存储,并且从下一级存储加载要读取的数据;如果缓存块不为脏,直接从下一级存储加载要读取的数据;
  4. 标记缓存块不为脏;
  5. 返回数据。

缓存高可用-客户端

在客户端配置多个缓存节点来提高缓存的可用性。一般的,4~6个节点。

写入数据:写入数据时需要做数据分片。一般的,我们使用一致性哈希算法,因为一致性哈希算法可以很好地解决增加或减少节点时缓存命中率下降的问题。

一致性哈希算法:将整个Hash值空间组织成一个虚拟的圆环,然后将缓存节点的IP地址或者主机名做Hash取值后,放置在这个圆环上。当我们需要确定某一个key需要存取到哪个节点时,在环上沿着顺时针方向找到的第一个缓存节点就是目标节点。在增加或减少节点时,只有少量的key会漂移到其他节点上,大部分key命中的节点保持不变,从而可以保证缓存命中率不会大幅下降。

一致性哈希算法的问题:

缓存高可用-中间代理层

客户端方案的劣势是通用性较差,不方便复用,把客户端方案的高可用逻辑单独抽离出来,就是中间代理层方案。

在应用程序和缓存节点之间增加代理层,客户端的写入和读取请求都通过代理层,代理层内置高可用策略。

中间代理层方案中所有的缓存读写请求都要经过中间代理层,代理层是无状态的,主要负责读写请求的路由功能,并且内嵌了高可用逻辑。

缓存高可用-服务端

Redis在2.4版本后提出了Redis Sentinel模式来解决主从Redis部署时的高可用问题。

Redis Sentinel也是集群部署的,Sentinel集群节点会监控主节点的状态,当主节点在一定时间内无响应,集群内部仲裁是否进行主从切换,主从切换则将某个从节点提升为主节点,并且把所有其他的从节点作为新主节点的从节点。

Redis Sentinel示意图

缓存穿透

缓存穿透是指未命中缓存而查询数据库。少量的缓存穿透是正常的,但是大量的缓存穿透就可能导致系统崩溃。

缓存穿透有两种典型的解决方案:

回种空值并设置一个较短的过期时间,这种方案最简单,需要注意的是空值缓存占用缓存容量问题。

布隆过滤器基于一个二进制数组和一个哈希算法,可以高效地判断一个元素是否在一个集合中。

使用布隆过滤器需要注意以下两点:

对于哈希碰撞导致的误判,问题不大,因为碰撞概率较低。当然,可以通过使用多个哈希算法计算多个哈希值,进一步降低碰撞概率。

对于删除元素,可以通过增减计数来实现,但是这样就需要占用更多的空间。

CDN(Content Distribution Network)

CDN,内容分发网络,通过将静态资源分发到位于多个地理位置机房中的服务器上,然后基于就近访问来加快静态资源的访问速度。

  1. 对静态资源请求做DNS解析,得到CNAME记录,映射到CDN域名;
  2. 通过GSLB(Global Server Load Balance)将请求映射到就近的CDN节点。

CDN域名解析示意图

消息

消息有哪些应用场景?

削峰示意图

异步示意图

解藕示意图

消息丢失

哪些场景下消息可能丢失呢?

生产者写消息队列时,比如网络抖动可能导致消息丢失,可以通过消息重传减少这种丢失。

消息队列内部存储出错,比如使用了异步刷盘机制,机器掉电或异常重启可能导致消息丢失,可以通过集群部署减少这种丢失。

消息重复

无论生产者还是消费者,都可以通过使用幂等id来保证幂等性。

消息延迟

首先,做好相关监控,可以通过官方或开源工具监控消息的堆积,也可以通过生成监控消息的方式来监控消息的延迟。

其次,从消费者的角度出发,可以考虑:

消息乱序

微服务

随着业务的发展,一体化架构在技术和管理上都会面临很多挑战,微服务就是为了更好地解决这些问题。

微服务化时的几点建议

微服务带来的挑战

RPC框架

RPC框架封装了网络调用的细节,让你像调用本地服务一样调用远程服务。

RPC调用过程示意图

要想保证RPC框架的性能,可以从网络传输和序列化两方面来考虑。

网络传输:

序列化:

如果对于性能要求不高,可以使用JSON;如果对于性能要求较高,可以使用Thrift或Protobuf.

注册中心

注册中心示意图

有了注册中心之后,服务节点的变更对客户端就是透明的,方便我们动态地变更服务节点,实现graceful shutdown等功能。

服务节点探活有两种方式:

主动探测不方便、成本高,一般的,我们使用心跳机制。 心跳机制示意图

分布式追踪

分布式追踪用于问题排查、性能优化、调用链展示、服务依赖分析等。

一般的,注意以下技术点的使用:

traceId+spanId示意图

负载均衡

负载均衡指的是将请求均衡地分配到不同服务节点中,避免单一节点流量过高或过低。同时,负载均衡可以起到对请求方屏蔽服务节点的部署细节,方便实现动态扩缩容。

负载均衡可以分为两大类:

代理类负载均衡以单独的服务方式部署,所有请求都要先经过这个服务,由这个服务选择合适的服务节点做流量的分发。 代理类负载均衡示意图

LVS(Linux Virtual Server)和Nginx是代理类负载均衡的范例。LVS工作在OSI模型的第四层传输层,Nginx工作在OSI模型的第七层应用层。对于大流量场景,可以同时部署LVS和Nginx来做HTTP应用服务的负载均衡,即在入口处部署LVS将流量分发到多个Nginx服务器上,再由Nginx服务器分发到应用服务器上。如果流量没有很大,也可以只通过Nginx做负载均衡,降低系统复杂度。

如何保证Nginx中配置的服务节点是可用的呢?可以通过nginx_upstream_check_module做服务探活,这个模块可以让Nginx定期地探测后端服务的一个指定的接口,根据接口返回的状态码来判断服务是否存活。当探测不存活次数达到一定阈值时,就自动将这个服务节点从负载均衡服务器中摘除。这种服务探活功能还可以用于Web服务的优雅关闭,通过修改探活接口返回的状态码来控制服务是否对外可用。

客户端负载均衡一般结合注册中心来使用,注册中心提供服务节点的列表,客户端拿到列表之后使用内嵌的负载均衡服务选择合适的节点做流量的分发。 客户端负载均衡示意图

负载均衡策略可以分为两大类:

API网关

API网关示意图

API网关可以分为两类:

入口网关部署在负载均衡服务器和应用服务器之间,可以提供以下功能:

出口网关部署在应用服务器和第三方系统之间,功能相对简单,主要用于对调用外部系统的API做统一的认证、授权以及审计等。

API网关需要注意的技术点:

多活

什么是多活?多活指的是在不同的IDC机房中部署多套服务,这些服务共享同一份业务数据,并且都可以承接来自用户的流量。

多活主要的难点在于跨机房数据传输导致的延迟对系统功能和性能的影响。

跨机房数据传输延迟时间:北京同城双机房延迟一般是1ms~3ms;北京和天津双机房延迟一般是10ms;北京和上海双机房延迟一般是30ms;北京和广州双机房延迟一般是50ms;国内和美国西海岸双机房延迟一般是100ms~200ms

同城多活相对简单,可以允许有跨机房数据写入的发生,数据的读取和服务的调用应当尽量保证在同一个机房中。

异地多活比较复杂,需要避免跨机房数据写入,涉及用户分片等问题。

监控

常用的监控指标包括:

常用组件监控指标: 常用组件监控指标

一般的,可以通过Agent、埋点、日志等方式来采集监控指标数据。

一般的,我们会通过消息队列承接监控数据。监控数据一方面会写入ElasticSearch,另一方面会通过流计算中间件来解析、聚合运算,然后写入时间序列数据库并形成报表对外展示。常见的报表包括:

监控系统架构示意图

APM(Application Performance Management),应用性能管理,指的是对应用各个层面做全方位的监测,核心关注点是终端用户的使用体验,即端到端整体链路上的性能情况。

APM系统示意图

压力测试

压力测试指的是在高并发大流量下进行的测试,通过观察系统在大负载下的表现,寻找系统性能的瓶颈点或隐患。

压测注意点:

一个自动化的全链路压测平台应当包含以下几个模块:

全链路压测系统架构示意图