Skip to content

《高并发系统设计40问》

介绍:极客时间专栏《高并发系统设计40问》读书笔记,课程地址:http://gk.link/a/10qj9

作者:Guide 哥

基础理论

基础

通用设计方法

应对高并发大流量归纳起来共有三种方法:

  • Scale-out(横向扩展):分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。
  • 缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击。
  • 异步:在某些场景下,未处理完成之前我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求。

⚠️注意 : 能使用单体解决的问题,就不要采用分布式。不能为了技术而技术,采用分布式固然可以分流用户请求,提高系统的响应能力,但同样也带来了复杂性。软件开发最终的目的是商业利益。罗马城不是一天就建立起来的。架构的工作应该是阶段性,解决阶段性系统的复杂性。如果单体跑的很好,或者通过 scale up 方式在成本可控的情况能解决就不要想着诗和远方,因为系统内部的进程间调用,肯定比不同物理机的进程之间调用要快。

为什么要架构分层?

网络、Linux 文件系统都是分层设计的。就比如TCP/IP 协议把网络简化成了四层,即链路层、网络层、传输层和应用层。每一层各司其职又互相帮助,网络层负责端到端的寻址和建立连接,传输层负责端到端的数据传输等,同时相邻两层还会有数据的交互。这样可以隔离关注点,让不同的层专注做不同的事情。

408c9e360c55765bd00b1aff80de382a.jpg

分层架构的好处:

  • 分层的设计可以简化系统设计,让不同的人专注做某一层次的事情。
  • 分层之后可以做到很高的复用。比如,我们在设计系统 A 的时候,发现某一层具有一定的通用性,那么我们可以把它抽取独立出来,在设计系统 B 的时候使用起来,这样可以减少研发周期,提升研发的效率。
  • 分层架构可以让我们更容易做横向扩展。如果系统没有分层,当流量增加时我们需要针对整体系统来做扩展。但是,如果我们按照上面提到的三层架构将系统分层后,就可以针对具体的问题来做细致的扩展。

分层架构的不足: (这些不足几乎可以忽略不计)

  • 增加了代码的复杂度
  • 如果把每个层次独立部署,层次间通过网络来交互,那么多层的架构在性能上会有损耗。

系统设计的目标

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

高并发指的是系统单位时间内请求量非常大,比如系统的 QPS(Query Per Second,服务器每秒可以执行的查询次数)大于 10万。

高并发系统设计的目标有三个:

  • 高性能 :系统的处理请求的速度很快,响应时间很短。
  • 高可用 :系统几乎可以一直正常提供服务。也就是说系统具备较高的无故障运行的能力。
  • 可扩展 :流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双 11 活动、明星离婚、明星恋爱等热点事件。

高性能

性能优化原则:

  • 性能优化一定不能盲目,一定是问题导向的。
  • 性能优化也遵循“八二原则”,即你可以用 20% 的精力解决 80% 的性能问题。
  • 性能优化也要有数据支撑。 在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。
  • 性能优化的过程是持续的,循序渐进的。

性能的度量指标:

度量性能的指标是系统接口的响应时间,但是单次的响应时间是没有意义的,你需要知道一段时间的性能情况是什么样的。所以,我们需要收集这段时间的响应时间数据,然后依据一些统计方法计算出特征值,这些特征值就能够代表这段时间的性能情况。

  • 平均值 : 平均值是把这段时间所有请求的响应时间数据相加,再除以总请求数。平均值可以在一定程度上反应这段时间的性能,但它敏感度比较差,如果这段时间有少量慢请求时,在平均值上并不能如实地反应。
  • 最大值 : 这段时间内所有请求响应时间最长的值。
  • 分位值 :分位值有很多种,比如 90 分位、95 分位、75 分位。以 90 分位为例,我们把这段时间请求的响应时间从小到大排序,假如一共有 100 个请求,那么排在第 90 位的响应时间就是 90 分位值。分位值排除了偶发极慢请求对于数据的影响,能够很好地反应这段时间的性能情况,分位值越大,对于慢请求的影响就越敏感。分位值是最适合作为时间段内,响应时间统计值来使用的,在实际工作中也应用最多。除此之外,平均值也可以作为一个参考值来使用。

1eb73981dfb18cfde5088c96d0c5cf1e.jpg

高可用

可用性的度量:

  • MTBF(Mean Time Between Failure): 平均故障间隔,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。
  • MTTR(Mean Time To Repair): 故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。

可用性与 MTBF 和 MTTR 的值息息相关,我们可以用下面的公式表示它们之间的关系:Availability = MTBF / (MTBF + MTTR)

73a87a9bc14a27c9ec9dfda1b72e1e75.jpg

可扩展

拆分是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。相对于大系统来说,考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化,这就是我们的思路。

未做拆分的系统虽然可扩展性不强,但是却足够简单,无论是系统开发还是运行维护都不需要投入很大的精力。拆分之后,需求开发需要横跨多个系统多个小团队,排查问题也需要涉及多个系统,运行维护上,可能每个子系统都需要有专人来负责,对于团队是一个比较大的考验。这个考验是我们必须要经历的一个大坎,需要我们做好准备。

数据库

池化技术

池化技术优缺点分析:

  • 优点:池化技术的核心思想是空间换时间,使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本。
  • 缺陷 : 存储对象会消耗多余的内存、对象没有被频繁使用的话会造成内存上的浪费。

这些缺陷相比池化技术的优势来说就比较微不足道了,只要我们确认要使用的对象在创建时确实比较耗时或者消耗资源,并且这些对象也确实会被频繁地创建和销毁,我们就可以使用池化技术来优化。

注意事项:

  • 池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。
  • 池子中的对象需要在使用之前预先初始化完成,这叫做池子的预热,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。

主从复制

通过主从分离和一主多从部署提高读取性能,抵抗更多的读流量。

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

  1. 一个是数据的拷贝,我们称为主从复制;
  2. 在主从分离的情况下,我们如何屏蔽主从分离带来的访问数据库方式的变化,让开发同学像是在使用单一数据库一样。

MySQL 主从复制了解么?

MySQL 的主从复制是依赖于 binlog 的,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上二进制日志文件。主从复制就是将 binlog 中的数据从主库传输到从库上,一般这个过程是异步的,即主库上的操作不会等待 binlog 同步的完成。

这是一种比较常见的主从复制方式,整个过程的图示如下:

575ef1a6dc6463e4c5a60a3752d8554d.jpg

  • 数据写入之后主库更新的 binlog
  • 主库创建一个 log dump 线程来发送 binlog 给从库
  • 从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog
  • 从库把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中
  • 从库会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。

MySQL 主从复制存在哪些隐患呢?

主库的写入流程并没有等待主从同步完成就会返回结果,那么在极端的情况下,比如说主库上 binlog 还没有来得及刷新到磁盘上就出现了磁盘损坏或者机器掉电,就会导致 binlog 的丢失,最终造成主从数据的不一致。不过,这种情况出现的概率很低,对于互联网的项目来说是可以容忍的。

无限制地增加从库的数量就可以抵抗大量的并发呢?

随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3 ~ 5 个从库。

还有可能存在什么问题?

主从延迟问题导致系统某些情况无法读取到最新保存的数据。

解决办法

  • 缓存:将数据线保存在缓存一份
  • 查询主库 :需要即时获取的数据改为从主库查

主从复制要点总结:

  • 主从读写分离以及部署一主多从可以解决突发的数据库读流量,是一种数据库横向扩展的方法;
  • 读写分离后,主从的延迟是一个关键的监控指标,可能会造成写入数据之后立刻读的时候读取不到的情况;
  • 业界有很多的方案可以屏蔽主从分离之后数据库访问的细节,让开发人员像是访问单一数据库一样,包括有像 TDDL、Sharding-JDBC 这样的嵌入应用内部的方案,也有像 Mycat 这样的独立部署的代理方案。

在使用主从复制这个技术点时,你一般会考虑两个问题:

  1. 主从的一致性和写入性能的权衡,如果你要保证所有从节点都写入成功,那么写入性能一定会受影响;如果你只写入主节点就返回成功,那么从节点就有可能出现数据同步失败的情况,从而造成主从不一致,而在互联网的项目中,我们一般会优先考虑性能而不是数据的强一致性。
  2. 主从的延迟问题,很多诡异的读取不到数据的问题都可能会和它有关,如果你遇到这类问题不妨先看看主从延迟的数据。

我们采用的很多组件都会使用到这个技术,比如,Redis 也是通过主从复制实现读写分离;Elasticsearch 中存储的索引分片也可以被复制到多个节点中;写入到 HDFS 中文件也会被复制到多个 DataNode 中。只是不同的组件对于复制的一致性、延迟要求不同,采用的方案也不同。但是这种设计的思想是通用的,是你需要了解的,这样你在学习其他存储组件的时候就能够触类旁通了。

分库分表

在面对数据库容量瓶颈和写并发量大的问题时,你可以采用垂直拆分和水平拆分来解决,不过你要注意,这两种方式虽然能够解决问题,但是也会引入诸如查询数据必须带上分区键,列表总数需要单独冗余存储等问题。

发号器/分布式 ID

可以使用 Snowflake 算法解决分库分表后数据库 ID 的全局唯一的问题。

Snowflake 的核心思想是将 64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。它的标准算法是这样的:

2dee7e8e227a339f8f3cb6e7b47c0c8d.jpg

从上面这张图中我们可以看到,41 位的时间戳大概可以支撑 pow(2,41)/1000/60/60/24/365 年,约等于 69 年,对于一个系统是足够了。

我们一般也会对 Snowflake 算法进行简单改造,比如在生成的 ID 中加入业务信息,这样出现问题的时候,我们通过 ID 就能知道是哪个业务的 ID 。

了解了 Snowflake 算法的原理之后,我们如何把它工程化,来为业务生成全局唯一的 ID 呢? 一般来说我们会有两种算法的实现方式:

  • 嵌入到业务代码里,也就是分布在业务服务器中 :不需要跨网络调用,性能上会好一些,但是就需要更多的机器 ID 位数来支持更多的业务服务器。另外,由于业务服务器的数量很多,我们很难保证机器 ID 的唯一性,所以就需要引入 ZooKeeper 等分布式一致性组件来保证每次机器重启时都能获得唯一的机器 ID。
  • 作为独立的服务部署,这也就是我们常说的发号器服务 : 无需引入第三方组件即可保证机器 ID 的唯一性。

NoSQL

数据库系统大多使用的是传统的机械磁盘,对于机械磁盘的访问方式有两种:

  • 随机 IO;
  • 顺序 IO ;

随机 IO 的读写效率要比顺序 IO 小两到三个数量级,所以我们想要提升写入的性能就要尽量减少随机 IO。

索引在 InnoDB 引擎中是 B+ 树结构,而 MySQL 主键是聚簇索引(一种索引类型,数据与索引数据放在一起),既然数据和索引数据放在一起,那么在数据插入或者更新的时候,我们需要找到要插入的位置,再把数据写到特定的位置上,这就产生了随机的 IO。而且一旦发生了页分裂,就不可避免会做数据的移动,也会极大地损耗写入性能。

NoSQL 数据库是怎么解决这个问题的呢?

它们有多种的解决方式,这里我给你讲一种最常见的方案,就是很多 NoSQL 数据库都在使用的基于 LSM 树的存储引擎,

LSM 树(Log-Structured Merge Tree)牺牲了一定的读性能来换取写入数据的高性能,Hbase、Cassandra、LevelDB 都是用这种算法作为存储的引擎。

NoSQL 的一些特点:

  1. 在性能方面,NoSQL 数据库使用一些算法将对磁盘的随机写转换成顺序写,提升了写的性能;
  2. 在某些场景下,比如全文搜索功能,关系型数据库并不能高效地支持,需要 NoSQL 数据库的支持;
  3. 在扩展性方面,NoSQL 数据库天生支持分布式,支持数据冗余和数据分片的特性。

缓存

  • 如何选择缓存读写策略?
  • 缓存如何做到高可用?
  • 缓存穿透怎么办?缓存雪崩怎么办?

消息队列

消息队列的主要作用:

  1. 削峰填谷 :消息队列的主要作用,主要用在秒杀场景。但是,会造成请求处理的延迟。
  2. 异步处理 :提升响应速度。
  3. 解耦合:提升系统的鲁棒性。

消息存在着丢失的风险,我们需要考虑如何确保消息一定到达

秒杀场景,使用消息队列的话,怎么保证秒杀产品不超卖?

主要有两种方法:

  • 使用锁的方式,比如分布式锁,也可以利用 redis 本身操作原子性的特点
  • 写入消息队列,在消息队列中做减库存的操作,做异步校验

如何保证消息仅仅被消费一次?

如何保证消息不被重复消费?

如何提升消费性能保证更短的消息延迟呢?

分布式服务篇

微服务

微服务的好处:

  1. 业务解耦、开发维护简单
  2. 不同模块可以使用不同的技术栈开发

使用不当可能存在的问题:胡乱拆分或者拆分不彻底是目前很常见的一个问题。这样会让你的项目陷入水深火热的状态,反而集成了单体和微服务的缺点。

相关问题:

  1. 为啥要用微服务?
  2. 怎么拆分微服务?粒度如何把握?

RPC 框架

通过 RPC 框架,能够解决服务之间跨网络通信的问题,这就完成了微服务化改造的基础。

实现高性能 RPC 框架的要素:

  1. IO 模型 :选择高性能的 I/O 模型,这里我推荐使用同步多路 I/O 复用模型;
  2. 网络参数 :调试网络参数,这里面有一些经验值的推荐。比如将 tcp_nodelay 设置为 true,也有一些参数需要在运行中来调试,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)等等;
  3. 序列化协议 :序列化协议依据具体业务来选择。如果对性能要求不高可以选择 JSON,否则可以从 Thrift 和 Protobuf 中选择其一。

注册中心

注册中心用来实现服务的注册和发现。

  • 注册中心提供了动态变更服务节点信息的功能,对于我们动态扩容、故障快速回复以及服务优雅关闭非常有帮助。
  • 心跳机制是一种常见的探测服务状态的方式,你在实际的项目中也可以考虑使用;
  • 我们需要对注册中心中管理的节点提供一些保护策略,避免节点被过度摘除导致的服务不可用。

分布式链路追踪

你的垂直电商系统在引入 RPC 框架和注册中心之后已经完成基本的服务化拆分了。

但是,不同于单体架构,在分布式架构下,请求需要在多个服务之间调用,排查问题会非常麻烦。

对于分布式链路追中,我们需要保证:

  1. 对代码要无侵入 :可以使用切面编程的方式来解决;
  2. 性能上要低损耗 :可以采用静态代理和日志采样的方式,来尽量减少追踪日志对于系统性能的影响;

无论是单体系统还是服务化架构,无论是服务追踪还是业务问题排查,你都需要在日志中增加 requestId,这样可以将你的日志串起来,给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成,在请求业务接口的时候传递给服务端,那么就可以把客户端的日志体系也整合进来,对于问题的排查帮助更大。

负载均衡

《从零开始学架构》这门课介绍了 3 中常见的负载均衡系统:DNS 负载均衡、硬件负载均衡和软件负载均衡。

LVS 和 Nginx

像 LVS 和 Nginx 都属于软件负载均衡系统。

Nginx 是软件的 7 层负载均衡,LVS 是 Linux 内核的 4 层负载均衡。4 层和 7 层的区别主要就在于协议和灵活性,Nginx 支持 HTTP、E-mail 协议;而 LVS 是 4 层负载均衡,和协议无关,几乎所有应用都可以做,例如,聊天、数据库等。

另外,Nginx 虽然比 LVS 的性能差很多,但也可以承担每秒几万次的请求,并且它在配置上更加灵活,还可以感知后端服务是否出现问题。

因此,LVS 适合在入口处承担大流量的请求分发,而 Nginx 要部署在业务服务器之前做更细维度的请求分发。

如果你的 QPS 在十万以内,那么可以考虑不引入 LVS 而直接使用 Nginx 作为唯一的负载均衡服务器,这样少维护一个组件,也会减少系统的维护成本。

如果想要追求更高的并发,我们也会同时部署 LVS 和 Nginx 来做 HTTP 应用服务的负载均衡。也就是说,在入口处部署 LVS 将流量分发到多个 Nginx 服务器上,再由 Nginx 服务器分发到应用服务器上

不过这两个负载均衡服务适用于普通的 Web 服务,对于微服务架构来说,它们是不合适的。因为微服务架构中的服务节点存储在注册中心里,使用 LVS 就很难和注册中心交互获取全量的服务节点列表。另外,一般微服务架构中,使用的是 RPC 协议而不是 HTTP 协议,所以 Nginx 也不能满足要求。

使用 Nginx 的话,可以引入淘宝开源的 nginx_upstream_check_module,对后端服务做定期的存活检测,后端的服务节点在重启时,也要秉承着“先切流量后重启”的原则,尽量减少节点重启对于整体系统的影响。

负载均衡策略

负载均衡的策略可以优先选择动态策略,保证请求发送到性能最优的节点上;如果没有合适的动态策略,那么可以选择轮询的策略,让请求平均分配到所有的服务节点上。

API 网关

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

API 网关的分类

API 网关可以分为两类:

  1. 入口网关。
  2. 出口网关。

入口网关是我们经常使用的网关种类,它部署在负载均衡服务器和应用服务器之间,主要有几方面的作用。

  1. 提供客户端一个统一的接入地址、API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。比如在你的系统中,你部署的微服务对外暴露的协议可能不同。
  2. 在 API 网关中,我们可以植入一些服务治理的策略,比如服务的 熔断降级、限流、分流等等。
  3. 客户端的认证和授权的实现,也可以放在 API 网关中
  4. API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
  5. API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,再比如分布式追踪系统中标记一次请求的 requestId 也可以在网关中来生成。

出口网关的功能就比较简单了!

我们在系统开发中,会依赖很多外部的第三方系统,典型的例子:第三方账户登录、使用第三方工具支付等等。我们可以在应用服务器和第三方系统之间,部署出口网关,在出口网关中,对调用外部的 API 做统一的认证、授权、审计以及访问控制。

cd4174a43b289b0538811293a93daf63.jpg

API 网关的实现

  1. 首要需要考虑性能! 毕竟 API 入口网关承担从客户端的所有流量,你要慢了就拖累了整个应用系统。比如 Zuul2.0 中,Netfix 团队将 servlet 改造成了一个 netty server(netty 服务),采用 I/O 多路复用的模型处理接入的 I/O 请求,并且将之前同步阻塞调用后端服务的方式,改造成使用 netty client(netty 客户端)非阻塞调用的方式。改造之后,Netfix 团队经过测试发现性能提升了 20% 左右。
  2. API 网关的设计要注意扩展性,也就是你可以随时在网关的执行链路上增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。

如何在你的系统中引入 API 网关?

API 网关可以替代原本系统中的 Web 层,将 Web 层中的协议转换、认证、限流等功能挪入到 API 网关中,将服务聚合的逻辑下沉到服务层。

总结

API 网关可以为 API 的调用提供便捷,也可以为将一些服务治理的功能独立出来,达到复用的目的,虽然在性能上可能会有一些损耗,但是一般来说,使用成熟的开源 API 网关组件,这些损耗都是可以接受的。所以,当你的微服务系统越来越复杂时,你可以考虑使用 API 网关作为整体系统的门面。

拓展:负载均衡 vs API 网站

负载均衡作用主要是将请求分流,而 API 网关作用主要是做服务治理比如熔断、限流。

多机房部署

  • 解释:多机房部署是指在不同的 IDC 机房中部署多套服务,这些服务共享同一份业务数据,并且都可以承接来自用户的流量。
  • why : 万一其中的某一个机房出现网络故障、火灾,甚至整个城市发生地震、洪水等大的不可抗的灾难时,你可以随时将用户的流量切换到其它地域的机房中,从而保证系统可以不间断地持续运行。

这种架构听起来非常美好,但是在实现上却是非常复杂和困难的!

不同机房的数据传输延迟是造成多机房部署困难的主要原因,你需要知道,同城多机房的延迟一般在 1ms~3ms,异地机房的延迟在 50ms 以下,而跨国机房的延迟在 200ms 以下。

Service Mesh

一个比较大的项目很可能会存在下面这样一种情况:不同的模块是基于不同的语言开发的。

而使用不同的语言开发出来的微服务,在相互调用时会存在两方面的挑战:

  1. 服务之间的通信协议上,要对多语言友好,要想实现跨语言调用,关键点是选择合适的序列化方式。
  2. 使用新语言开发的微服务,无法使用之前积累的服务治理的策略。

如何让服务治理的策略在多语言之间复用呢?

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

维护篇

服务端监控怎么做?

监控指标如何选择?

搭建监控系统时免得第一个问题就是 监控指标的选择

一些成熟的理论和套路你可以直接拿来使用的,比如谷歌总结的 四个黄金信号(Four Golden Signals):延迟通信量错误饱和度

这四个黄金信号提供了通用的监控指标,除此之外,你还可以借鉴 RED 指标体系。这个体系是从四个黄金信号中衍生出来的,少了饱和度的指标。你可以把它当作一种简化版的通用监控指标体系。

  • R 代表请求量(Request rate)
  • E 代表错误(Error)
  • D 代表响应时间(Duration),

业务上常见的监控指标

1a29724ee8a33593797a5947d765f11a.jpg

数据采集的 3 种方式

  1. Agent
  2. 埋点
  3. 日志

监控数据的处理和存储

采集到监控数据之后,你就可以对它们进行处理和存储了。在此之前,我们一般会先用消息队列来承接数据,主要的作用是削峰填谷,防止写入过多的监控数据,让监控服务产生影响。

我们一般会部署两个队列处理程序,来消费消息队列中的数据:

  1. 一个处理程序接收到数据后,把数据写入到 Elasticsearch,然后通过 Kibana 展示数据,这些数据主要是用来做原始数据的查询
  2. 另一个处理程序是一些流式处理的中间件,比如 Spark、Storm。它们从消息队列里接收数据后会做一些处理。

监控手段

监控手段还是不少的,Grafana ,Skywalking,Prometheus 等, 另外还可以结合 Nginx、 Flume 、Kafka 、ELK 等日志收集做自己的系统分析。

相关阅读:监控系统选型,这篇不可不读!

应用性能管理

有一些问题,服务端的监控报表无法排查,甚至无法感知。比如,有用户反馈创建订单失败,但是从服务端的报表来看,并没有什么明显的性能波动。那么,当我们遇到这类问题时,要如何排查和优化呢?

应用性能管理(Application Performance Management,简称 APM),它的含义是:对应用各个层面做全方位的监测,期望及时发现可能存在的问题,并加以解决,从而提升系统的性能和可用性。

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

压力测试

配置管理

  1. 配置中心的配置是需要分级的,如全局,机房,个性,可以减少存储,提高复用性。
  2. 当前配置中心主要有拉和推两种,拉就是定时去轮循获取,这里为了减少带宽,一开始是拿着本地的 md5 去看下是否配置有变更,如果没有变更,就不用拉取,如果有变更,再去拉取新的配置项。推就是需要保持长连接了,服务端还要维护客户端那边关注的配置项,一旦有配置项变更,就通知客户端。
  3. 配置中心是可用性高于性能的,配置中心客户端除了要维护内存配置项,还要维护一个文件的配置项,这个是异步同步的,一旦配置中心服务器挂了,可以降级读取本地文件的配置项内容。

降级熔断

在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务。

熔断机制

熔断机制参考的是电路中保险丝的保护机制,当电路超负荷运转的时候,保险丝会断开电路,保证整体电路不受损害。而服务治理中的熔断机制指的是在发起服务调用的时候,如果返回错误或者超时的次数超过一定阈值,则后续的请求不再发向远程服务而是暂时返回错误。

这种实现方式在云计算领域又称为断路器模式,在这种模式下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机中会有三种状态:

  • 关闭(调用远程服务)
  • 半打开(尝试调用远程服务)
  • 打开(返回错误)

9fc3934e1e0923fe990e0bdbe3aec787.jpg

系统设计实战

计数系统设计(1):海量数据的计数系统如何设计?

使用场景

这里拿微博来举例:

  • 微博的评论数、点赞数、转发数、浏览数、表态数等等;
  • 用户的粉丝数、关注数、发布微博数、私信数等等。

技术系统的特点

  1. 数据量大 :帖子的转发、评论、点赞、浏览等核心计数可能达到千亿级别。
  2. 访问量大 : 技术系统的访问量可能达到百万qps
  3. 准确性 :对于数字的准确性要求非常高!敏

如何设计?

访问量不是很大的时候:

MySQL- >主从架构->分库分表

当访问量剧增的时候:

如果计数系统访问量非常大,仅仅靠数据库已经完全不能承担如此高的并发量了。于是架构变为:

Redis+MySQL

并且,我们以集群方式来部署Redis 以提供可用性和性能,并且通过 Hash 的方式对数据做分片。

但是,这种架构下我们需要保证数据库和缓存数据的一致性。

如何提升计数的写入性能?

由于热门帖子的计数变化频率相当高,也需要考虑如何提升计数的写入性能。

我们可以利用消息队列来削峰填谷,当某个帖子发生变动的时候我们直接写入到消息队列中一条对应的消息即可。

我们可以通过批量处理消息的方式进一步减小 Redis 的写压力,比如将多次更新数据库变为1次(造成的最终效果一样就行)。

举个例子:某个帖子点赞增加了3个,我们直接更新一次数据库将点赞数加3即可。

如何降低计数系统的存储成本

Redis 是使用内存来存储信息,相比于使用磁盘存储数据的 MySQL 来说,存储的成本高了太多太多。

你可以对对原生 Redis 做一些改造,采用新的数据结构和数据类型来存储计数数据。我在改造时,主要涉及了两点:

  1. Key 使用 Long 类型原生的 Redis 在存储 Key 时是按照字符串类型来存储的,比如一个 8 字节的 Long 类型的数据,需要 8(sdshdr 数据结构长度)+ 19(8 字节数字的长度)+1(’\0’)=28 个字节,如果我们使用 Long 类型来存储就只需要 8 个字节,会节省 20 个字节的空间;
  2. 去除原生 Redis 中多余的指针 :如果要存储一个 KV 信息就只需要 8(weibo_id)+4(转发数)=12 个字节,相比之前有很大的改进。

同时,我们也会使用一个大的数组来存储计数信息,存储的位置是基于 weibo_id 的哈希值来计算出来的。

在对原生的 Redis 做了改造之后,你还需要进一步考虑如何节省内存的使用。比如,微博的计数有转发数、评论数、浏览数、点赞数等等,如果每一个计数都需要存储 weibo_id,那么总共就需要 8(weibo_id)*4(4 个微博 ID)+4(转发数) + 4(评论数) + 4(点赞数) + 4(浏览数)= 48 字节。但是我们可以把相同微博 ID 的计数存储在一起,这样就只需要记录一个微博 ID,省掉了多余的三个微博 ID 的存储开销,存储空间就进一步减少了。

即使经过上面的优化,由于计数的量级实在是太过巨大,并且还在以极快的速度增长,所以如果我们以全内存的方式来存储计数信息,就需要使用非常多的机器来支撑。

然而微博计数的数据具有明显的热点属性:越是最近的微博越是会被访问到,时间上久远的微博被访问的几率很小。所以为了尽量减少服务器的使用,我们考虑给计数服务增加 SSD 磁盘,然后将时间上比较久远的数据 dump 到磁盘上,内存中只保留最近的数据。当我们要读取冷数据的时候,使用单独的 I/O 线程异步地将冷数据从 SSD 磁盘中加载到一块儿单独的 Cold Cache 中。

16cb144c96a0ab34214c966f686c9693.jpg

最终方案:

MySQL + Redis  + 消息队列 + SSD+ 内存

思考

能不能使用 Tidb 这里 NOSQL 解决上面遇到的一些问题呢?

计数系统设计(2):未读数系统如何设计?

有一类特殊的计数并不能完全使用我们提到的方案,那就是未读数

未读数也是系统中一个常见的模块比如当有人 @你、评论你、给你的博文点赞或者给你发送私信的时候,你会收到相应的未读提醒。

要如何记录未读数呢?

解决方法很简单!你只需要计数系统以用户 ID 为 Key 存储多个未读数即可比如当有人 @ 你时,增加你的未读 @的计数;当有人评论你时,增加你的未读评论的计数。

但是!!!系统通知的未读数没办法通过这种方式来实现。

为什么呢?

系统通知往往是面向系统全体用户的,你一个一个的去增加每个用户的未读数是一个很傻的做法。

就算给一个用户增加未读数只需要消耗 1ms,那么给100万个人用户增加未读数一次都需要1000s,换算下来差不多是 41 分钟。

即使是采用多线层处理,这个耗时也是无法接受的!

那要怎么做呢?

你可以将系统通知存入一个列表中,这个列表对所有用户共享。你可以记录一下在这个列表中每个人看过最后一条消息的 ID,然后统计这个 ID 之后有多少条消息,这就是未读数了。

a5f0b6776246dc6b4c7e96c72d74a210.jpg

这个方案在实现时有这样几个关键点:

  1. 用户访问系统通知页面需要设置未读数为 0,我们需要将用户最近看过的通知 ID 设置为最新的一条系统通知 ID;
  2. 如果最近看过的通知 ID 为空,则认为是一个新的用户,返回未读数为 0;
  3. 对于非活跃用户,比如最近一个月都没有登录和使用过系统的用户,可以把用户最近看过的通知 ID 清空,节省内存空间。

这是一种比较通用的方案,既节省内存,又能尽量减少获取未读数的延迟。

ae6a5e9e04be08d18c493729458d543f.jpg

上面👆这种红点和系统通知类似,也是一种通知全量用户的手段,如果逐个通知用户,延迟也是无法接受的。因此你可以采用和系统通知类似的方案。

具体方案如何:

  1. 为每一个用户存储一个最近一次点这个红点的时间戳  。
  2. 记录一个全局的时间戳标识最新的一次打点时间。如果你在后台操作给全体用户打点,就更新这个时间戳为当前时间。
  3. 只需要判断用户的时间戳和全局时间戳的大小来确认是否展示红点。如何设计?

基于关注的信息流的未读数还是比较复杂的:

  1. 微博的信息流是基于关注关系的,未读数也是基于关注关系的,就是说,你关注的人发布了新的微博,那么你作为粉丝未读数就要增加 1。
  2. 信息流未读数请求量极大、并发极高,这是因为接口是客户端轮询请求的,不是用户触发的。也就是说,用户即使打开微博客户端什么都不做,这个接口也会被请求到。
  3. 不像系统通知那样有共享的存储,因为每个人关注的人不同,信息流的列表也就不同,所以也就没办法采用系统通知未读数的方案。

如何设计能够承接每秒几十万次请求的信息流未读数系统呢?

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

a563b121ae1147a2d877a7bb14c9658a.jpg

这个方案设计简单,并且是全内存操作,性能足够好,能够支撑比较高的并发,事实上微博团队仅仅用 16 台普通的服务器就支撑了每秒接近 50 万次的请求,这就足以证明这个方案的性能有多出色,因此,它完全能够满足信息流未读数的需求。

当然了这个方案也有一些缺陷,比如说快照中需要存储关注关系,如果关注关系变更的时候更新不及时,那么就会造成未读数不准确。

一些建议:

  1. 缓存是提升系统性能和抵抗大并发量的神器,像是微博信息流未读数这么大的量级我们仅仅使用十几台服务器就可以支撑,这全都是缓存的功劳;
  2. 要围绕系统设计的关键困难点想解决办法,就像我们解决系统通知未读数的延迟问题一样;
  3. 合理分析业务场景,明确哪些是可以权衡的,哪些是不行的,会对你的系统设计增益良多,比如对于长久不登录用户,我们就会记录未读数为 0,通过这样的权衡,可以极大地减少内存的占用,减少成本。

计数系统的业务逻辑其实非常简单,基本上最多只有三个接口:

  1. 获取计数
  2. 增加计数
  3. 重置计数。

基本上使用缓存就可以实现一个兼顾性能、可用性和鲁棒性的方案了。

接下来,我们来看看如何设计社区系统中最为复杂、并发量也最高的信息流系统

信息流设计(1):推模式

设计信息流系统的关注点有哪些

  1. 实时性 :你关注的人发了微博信息之后,信息需要在短时间之内出现在你的信息流中。
  2. 高并发 :信息流是微博的主体模块,是用户进入到微博之后最先看到的模块,因此它的并发请求量是最高的,可以达到每秒几十万次请求。
  3. 性能 : 信息流拉取性能直接影响用户的使用体验。微博信息流系统中需要聚合的数据非常多。聚合这么多的数据就需要查询多次缓存、数据库、计数器,而在每秒几十万次的请求下,如何保证在 100ms 之内完成这些查询操作,展示微博的信息流呢?这是微博信息流系统最复杂之处,也是技术上最大的挑战。

如何基于推模式实现信息流系统?

什么是推模式?

推模式是指用户发送一条微博后,主动将这条微博推送给他的粉丝,从而实现微博的分发,也能以此实现微博信息流的聚合。

假如用户 A 有三个粉丝 B、C、D,如果用 SQL 表示 A 发布一条微博时系统做的事情,那么就像下面展示的这个样子:

sql
insert into outbox(userId, feedId, create_time) values("A", $feedId, $current_time); //写入A的发件箱
insert into inbox(userId, feedId, create_time) values("B", $feedId, $current_time); //写入B的收件箱
insert into inbox(userId, feedId, create_time) values("C", $feedId, $current_time); //写入C的收件箱
insert into inbox(userId, feedId, create_time) values("D", $feedId, $current_time); //写入D的收件箱

当我们要查询 B 的信息流时,只需要执行下面这条 SQL 就可以了:

sql
select feedId from inbox where userId = "B";

如果你想要提升读取信息流的性能,可以把收件箱的数据存储在缓存里面,每次获取信息流的时候直接从缓存中读取就好了。

有什么缺陷呢?

可以参考未读消息系统中的系统信息模块的设计。这里也存在过量操作数据库的嫌疑,十分 影响使用体验。

并且,推模式下,我们需要给每一个用户都维护一份收件箱的数据,所以数据的存储量极大,

推模式数据库表设计

在这个方案中我们一般会这么来设计表结构:

  • Feed 表 :存储微博的基本信息(微博 ID、创建人的 ID、创建时间、微博内容、微博状态(删除还是正常)等等)。可以使用微博 ID 做哈希分库分表;
  • TimeLine (时间线)表 :用户的发件箱和收件箱表,主要有三个字段,用户 ID、微博 ID 和创建时间。可以使用用户的 ID 做哈希分库分表。

71b4b33d966a7e34a62f635a1a23646c.jpg

我们的解决思路是: 除了选择压缩率更高的存储引擎之外,还可以定期地清理数据,因为用户更加关注最近几天发布的数据,通常不会翻阅很久之前的微博,所以你可以定期地清理用户的收件箱,比如只保留最近 1 个月的数据就可以了。

除此之外,推模式下我们还通常会遇到扩展性的问题。在微博中有一个分组的功能,它的作用是你可以将关注的人分门别类,比如你可以把关注的人分为“明星”“技术”“旅游”等类别。

如果杨幂发了一条微博,那么不仅需要插入到我的收件箱中,还需要插入到我的“明星”收件箱中,这样不仅增加了消息分发的压力,同时由于每一个收件箱都需要单独存储,所以存储成本也就更高。

在读取自己信息流的时候,判断每一条微博是否被删除以及你是否还关注这条微博的作者,如果没有的话,就不展示这条微博的内容了。使用了这个策略之后,就可以尽量减少对于数据库多余的写操作了。

推模式究竟适合什么样的业务的场景?

较适合于一个用户的粉丝数比较有限的场景,比如说微信朋友圈。

总结

推模式下,用户发送一条消息,除了写入微博消息表以外,还要写入所有粉丝的的收件箱中(可以用缓存提高性能),然后用户去收件箱中读取消息即可。

推模式最大的确实就是多余存储

信息流设计(2):拉模式

所谓拉模式,就是指用户主动拉取他关注的所有人的微博,将这些微博按照发布时间的倒序进行排序和聚合之后,生成信息流数据的方法。

假设用户 A 关注了用户 B、C、D,那么当用户 B 发送一条微博的时候,他会执行这样的操作:

sql
insert into outbox(userId, feedId, create_time) values("B", $feedId, $current_time); //写入B的发件箱

当用户 A 想要获取他的信息流的时候,就要聚合 B、C、D 三个用户收件箱的内容了:

sql
select feedId from outbox where userId in (select userId from follower where fanId = "A") order by create_time desc

你看,拉模式的实现思想并不复杂,并且相比推模式来说,它有几点明显的优势。

  1. 解决了推送延迟问题
  2. 存储成本大大降低了:在拉模式下只保留了发件箱(微博信息表),微博数据不再需要复制,成本也就随之降低了。
  3. 功能扩展性更好了:

拉模式也会有一些问题,在我看来主要有这样两点。

  1. 查询和聚合的成本比较高 :不同于推模式下获取信息流的时候,只是简单地查询收件箱中的数据,在拉模式下,我们需要对多个发件箱的数据做聚合。微博的关注上限是 2000,假如你关注了 2000 人,就要查询这 2000 人发布的微博信息,然后再对查询出来的信息做聚合。如何保证在毫秒级别完成这些信息的查询和聚合呢?答案还是缓存
  2. 缓存节点的带宽成本比较高   :所以我们可以通过一些权衡策略尽量减少获取数据的大小,以及部署缓存副本的方式来抗并发;

推拉结合的模式核心是只推送活跃的粉丝用户,需要维护用户的在线状态以及活跃粉丝的列表,所以需要增加多余的空间成本来存储,这个你需要来权衡。

拉模式和推拉结合模式比较适合微博这种粉丝量很大的业务场景,因为它们都会有比较可控的消息推送延迟。

结束语

现在的知识偏向碎片化,如何有条理、系统地学习,将知识梳理成体系,化作自己的内功,是比较关键和困难的。几点建议:

  1. 计算机基础知识 :基础知识要体系化,读书是一种很好的获取体系化知识的途径,比如研读《算法导论》提升对数据结构和算法的理解,研读《TCP/IP 协议详解》深入理解我们最熟悉的 TCP/IP 协议栈等等;
  2. 经典项目源码 :多读一些经典项目的源代码,比如 Dubbo,Spring 等等,从中领会设计思想,你的编码能力会得到极大的提高;
  3. 碎片化时间学习  :多利用碎片化的时间读一些公众号的文章,弥补书里没有实践案例的不足,借此提升技术视野。

补充:

1、一定要深入学习,学会总结沉淀,尤其是在自己的领域内。

针对那些对自己经常会用到的技术,不要仅仅停留在表面。

不会进行思考总结,你做再多的项目,了解再多的技术又如何?可能就只是表面上好看而已,有些东西永远都成为不了自己的。对应到我们平时学习技术的时候也是一样,记得一定要多总结思考!

2、避免货物崇拜编程

货物编程就是我们不明就理地使用各种框架/优秀实践(比如设计模式)/软件架构,最后把项目搞得像个四不像。

列举一些我身边发生过的实际的例子吧!

  • 看到一些比较火的框架就直接套用在自己的项目上,而不知道这个框架究竟能解决项目上的什么问题?是否适合项目?有没有什么风险?
  • 学习了某个设计模式/工程实践之后,不顾项目实际情况,刻意使用在项目上!
  • 直接复制从网上(比如 Stack Overflow )找到的代码,只要运行 OK 就好。
  • 看到一些比较火的概念就魔怔了,比如前两年开始爆火的中台概念。

更新: 2021-09-24 06:49:06
原文: https://www.yuque.com/snailclimb/to3hqu/sabhs9

Java 后端面试知识库