1 - CH01-基础-设计方法

我们知道,高并发代表着大流量,高并发系统设计的魅力就在于我们能够凭借自己的聪明才智设计巧妙的方案,从而抵抗巨大流量的冲击,带给用户更好的使用体验。这些方案好似能操纵流量,让流量更加平稳得被系统中的服务和组件处理。

来做个简单的比喻吧。

从古至今,长江和黄河流域水患不断,远古时期:

  • 大禹曾拓宽河道,清除淤沙让流水更加顺畅
  • 都江堰作为史上最成功的的治水案例之一,用引流将岷江之水分流到多个支流中,以分担水流压力
  • 三门峡和葛洲坝通过建造水库将水引入水库先存储起来,然后再想办法把水库中的水缓缓地排出去,以此提高下游的抗洪能力。

而我们在应对高并发大流量时也会采用类似 抵御洪水 的方案,归纳起来共有三种方法:

  • Scale-out(横向扩展)

    分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。

  • 缓存

    使用缓存来提高系统的性能,就好比用 「拓宽河道」的方式抵抗高并发大流量的冲击。

  • 异步

    在某些场景下,未处理完成之前,我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求

简单介绍了这三种方法之后,我再详细地带你了解一下,这样当你在设计高并发系统时就可以有考虑的方向了。当然了,这三种方法会细化出更多的内容,我会在后面的课程中深入讲解。

首先,我们先来了解第一种方法:Scale-out。

Scale-up vs Scale-out

著名的「摩尔定律」是由 Intel 的创始人之一戈登·摩尔于 1965 年提出的。这个定律提到,集成电路上可容纳的晶体管的数量约每隔两年会增加一倍

后来,Intel 首席执行官大卫·豪斯提出「18 个月」的说法,即预计 18 个月会将芯片的性能提升一倍,这个说法广为流传。

摩尔定律虽然描述的是芯片的发展速度,但我们可以延伸为整体的硬件性能,从 20 世纪后半叶开始,计算机硬件的性能是指数级演进的。

直到现在,摩尔定律依然生效,在半个世纪以来的 CPU 发展过程中,芯片厂商靠着在有限面积上做更小的晶体管的黑科技,大幅度地提升着芯片的性能。从第一代集成电路上只有十几个晶体管,到现在一个芯片上动辄几十亿晶体管的数量,摩尔定律指引着芯片厂商完成了技术上的飞跃。

但是有专家预测,摩尔定律可能在未来几年之内不再生效,原因是目前的芯片技术已经做到了 10nm 级别,在工艺上已经接近极限,再往上做,即使有新的技术突破,在成本上也难以被市场接受。后来,双核和多核技术的产生拯救了摩尔定律,这些技术的思路是将多个 CPU 核心压在一个芯片上,从而大大提升 CPU 的并行处理能力

我们在高并发系统设计上也沿用了同样的思路:

  • 将类似追逐摩尔定律不断提升 CPU 性能的方案叫做 Scale-up(纵向扩展)

    容纳更多的晶体管

  • 把类似 CPU 多核心的方案叫做 Scale-out

    单核心变多核心

这两种思路在实现方式上是完全不同的。

  • Scale-up

    通过购买性能更好的硬件来提升系统的并发处理能力,比方说目前系统 4 核 4G 每秒可以处理 200 次请求,那么如果要处理 400 次请求呢?很简单,我们把机器的硬件提升到 8 核 8G(硬件资源的提升可能不是线性的,这里仅为参考)。

  • Scale-out

    则是另外一个思路,它通过将多个低性能的机器组成一个分布式集群来共同抵御高并发流量的冲击。沿用刚刚的例子,我们可以使用两台 4 核 4G 的机器来处理那 400 次请求。

**那么什么时候选择 Scale-up,什么时候选择 Scale-out 呢?**一般来讲,在我们系统设计初期会考虑使用 Scale-up 的方式,因为这种方案足够简单,所谓能用堆砌硬件解决的问题就用硬件来解决,但是当系统并发超过了单机的极限时,我们就要使用 Scale-out 的方式。

Scale-out 虽然能够突破单机的限制,但也会引入一些复杂问题。比如,如果某个节点出现故障如何保证整体可用性?当多个节点有状态需要同步时,如何保证状态信息在不同节点的一致性?如何做到使用方无感知的增加和删除节点?等等。其中每一个问题都涉及很多的知识点,我会在后面的课程中深入地讲解,这里暂时不展开了。

说完了 Scale-out,我们再来看看高并发系统设计的另一种方法:缓存。

使用缓存提升性能

Web 2.0 是缓存的时代,这一点毋庸置疑。缓存遍布在系统设计的每个角落,从操作系统到浏览器,从数据库到消息队列,任何略微复杂的服务和组件中,你都可以看到缓存的影子。我们使用缓存的主要作用是提升系统的访问性能,那么在高并发的场景下,就可以支撑更多用户的同时访问。

那么为什么缓存可以大幅度提升系统的性能呢?我们知道数据是放在持久化存储中的,一般的持久化存储都是使用磁盘作为存储介质的,而普通磁盘数据由机械手臂、磁头、转轴、盘片组成,盘片又分为磁道、柱面和扇区,盘片构造图我放在下面了。

NAME

盘片是存储介质,每个盘片被划分为多个同心圆,信息都被存储在同心圆之中,这些 同心圆就是磁道。在磁盘工作时盘片是在高速旋转的,机械手臂驱动磁头沿着径向移动,在磁道上读取所需要的数据。我们把 磁头寻找信息花费的时间叫做寻道时间

普通磁盘的寻道时间是 10ms 左右,而相比于磁盘寻道花费的时间,CPU 执行指令和内存寻址的时间都在是 ns(纳秒)级别,从千兆网卡上读取数据的时间是在 μs(微秒)级别。所以在整个计算机体系中,磁盘是最慢的一环,甚至比其它的组件要慢几个数量级。因此,我们通常使用以内存作为存储介质的缓存,以此提升性能。

当然,缓存的语义已经丰富了很多,我们 可以将任何降低响应时间的中间存储都称为缓存。缓存的思想遍布很多设计领域,比如在操作系统中 CPU 有多级缓存,文件有 Page Cache 缓存,你应该有所了解。

异步处理

异步 也是一种常见的高并发设计方法,我们在很多文章和演讲中都能听到这个名词,与之共同出现的还有它的反义词:同步。比如,分布式服务框架 Dubbo 中有同步方法调用和异步方法调用,IO 模型中有同步 IO 和异步 IO。

那么什么是同步,什么是异步呢? 以方法调用为例,同步调用代表调用方要阻塞等待被调用方法中的逻辑执行完成。这种方式下,当被调用方法响应时间较长时,会造成调用方长久的阻塞,在高并发下会造成整体系统性能下降甚至发生雪崩。

异步调用恰恰相反,调用方不需要等待方法逻辑执行完成就可以返回执行其他的逻辑,在被调用方法执行完毕后再通过回调、事件通知等方式将结果反馈给调用方。

异步调用在大规模高并发系统中被大量使用,比如我们熟知的 12306 网站。 当我们订票时,页面会显示系统正在排队,这个提示就代表着系统在异步处理我们的订票请求。在 12306 系统中查询余票、下单和更改余票状态都是比较耗时的操作,可能涉及多个内部系统的互相调用,如果是同步调用就会像 12306 刚刚上线时那样,高峰期永远不可能下单成功。

而采用异步的方式,后端处理时会把请求丢到消息队列中,同时快速响应用户,告诉用户我们正在排队处理,然后释放出资源来处理更多的请求。订票请求处理完之后,再通知用户订票成功或者失败。

处理逻辑后移到异步处理程序中,Web 服务的压力小了,资源占用的少了,自然就能接收更多的用户订票请求,系统承受高并发的能力也就提升了。

NAME

既然我们了解了这三种方法,那么是不是意味着在高并发系统设计中,开发一个系统时要把这些方法都用上呢?当然不是,系统的设计是不断演进的

罗马不是一天建成的,系统的设计也是如此。 不同量级的系统有不同的痛点,也就有不同的架构设计的侧重点。如果都按照百万、千万并发来设计系统,电商一律向淘宝看齐,IM 全都学习微信和 QQ,那么这些系统的命运一定是灭亡。

因为淘宝、微信的系统虽然能够解决同时百万、千万人同时在线的需求,但其内部的复杂程度也远非我们能够想象的。盲目地追从只能让我们的架构复杂不堪,最终难以维护。就拿从单体架构往服务化演进来说,淘宝也是在经历了多年的发展后,发现系统整体的扩展能力出现问题时,开始启动服务化改造项目的。

我之前也踩过一些坑, 参与的一个创业项目在初始阶段就采用了服务化的架构,但由于当时人力有限,团队技术积累不足,因此在实际项目开发过程中,发现无法驾驭如此复杂的架构,也出现了问题难以定位、系统整体性能下降等多方面的问题,甚至连系统宕机了都很难追查到根本原因,最后不得不把服务做整合,回归到简单的单体架构中。

所以我建议一般系统的演进过程应该遵循下面的思路:

  • 最简单的系统设计满足业务需求和流量现状,选择最熟悉的技术体系。
  • 随着流量的增加和业务的变化,修正架构中存在问题的点,如单点问题,横向扩展问题,性能无法满足需求的组件。在这个过程中,选择社区成熟的、团队熟悉的组件帮助我们解决问题,在社区没有合适解决方案的前提下才会自己造轮子。
  • 当对架构的小修小补无法满足需求时,考虑重构、重写等大的调整方式以解决现有的问题。

以淘宝为例, 当时在业务从 0 到 1 的阶段是通过购买的方式快速搭建了系统。而后,随着流量的增长,淘宝做了一系列的技术改造来提升高并发处理能力,比如数据库存储引擎从 MyISAM 迁移到 InnoDB,数据库做分库分表,增加缓存,启动中间件研发等。

当这些都无法满足时就考虑对整体架构做大规模重构,比如说著名的「五彩石」项目让淘宝的架构从单体演进为服务化架构。正是通过逐步的技术演进,淘宝才进化出如今承担过亿 QPS 的技术架构。

归根结底一句话:高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。

课程小结

在今天的课程中,我带着你了解了高并发系统设计的三种通用方法:Scale-out、缓存和异步。 这三种方法可以在做方案设计时灵活地运用,但它不是具体实施的方案,而是三种思想,在实际运用中会千变万化。

就拿 Scale-out 来说,数据库一主多从、分库分表、存储分片都是它的实际应用方案。而我们需要注意的是,在应对高并发大流量的时候,系统是可以通过增加机器来承担流量冲击的,至于要采用什么样的方案还是要具体问题具体分析。

2 - CH02-基础-架构分层

在系统从 0 到 1 的阶段,为了让系统快速上线,我们通常是不考虑分层的。但是随着业务越来越复杂,大量的代码纠缠在一起,会出现逻辑不清晰、各模块相互依赖、代码扩展性差、改动一处就牵一发而动全身等问题

这时,对系统进行分层就会被提上日程,那么我们要如何对架构进行分层?架构分层和高并发架构设计又有什么关系呢?本节课,我将带你寻找答案。

什么是分层架构

软件架构分层在软件工程中是一种常见的设计方式,它是将整体系统拆分成 N 个层次,每个层次有独立的职责,多个层次协同提供完整的功能。

我们在刚刚成为程序员的时候,会被「教育」说系统的设计要是「MVC」(Model-View-Controller)架构。它将整体的系统分成了 Model(模型),View(视图)和 Controller(控制器)三个层次,也就是将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了 表现和逻辑的解耦,是一种标准的软件分层架构。

NAME

另外一种常见的分层方式是将整体架构分为表现层、逻辑层和数据访问层:

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

这是在架构上最简单的一种分层方式。其实,我们在不经意间已经按照三层架构来做系统分层设计了,比如在构建项目的时候,我们通常会建立三个目录:Web、Service 和 Dao,它们分别对应了表现层、逻辑层还有数据访问层。

NAME

除此之外,如果我们稍加留意,就可以发现很多的分层的例子。比如我们在大学中学到的 OSI 网络模型,它把整个网络分了七层,自下而上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

NAME

工作中经常能用到 TCP/IP 协议,它把网络简化成了四层(如上图右侧),即链路层、网络层、传输层和应用层。每一层各司其职又互相帮助,网络层负责端到端的寻址和建立连接,传输层负责端到端的数据传输等,同时呢相邻两层还会有数据的交互。这样可以 隔离关注点,让不同的层专注做不同的事情

Linux 文件系统也是分层设计的,从下图你可以清晰地看出文件系统的层次。

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

我们可以看到,某些层次负责的是对下层不同实现的抽象,从而对上层屏蔽实现细节。比方说 VFS 对上层(系统调用层)来说提供了统一的调用接口,同时对下层中不同的文件系统规约了实现模型,当新增一种文件系统实现的时候,只需要按照这种模型来设计,就可以无缝插入到 Linux 文件系统中。

那么,为什么这么多系统一定要做分层的设计呢?答案是分层设计存在一定的优势。

分层有什么好处

**分层的设计可以简化系统设计,让不同的人专注做某一层次的事情。**想象一下,如果你要设计一款网络程序却没有分层,该是一件多么痛苦的事情。

因为你必须是一个通晓网络的全才,要知道各种网络设备的接口是什么样的,以便可以将数据包发送给它。你还要关注数据传输的细节,并且需要处理类似网络拥塞,数据超时重传这样的复杂问题。当然了,你更需要关注数据如何在网络上安全传输,不会被别人窥探和篡改。

而有了分层的设计,你只需要专注设计应用层的程序就可以了,其他的,都可以交给下面几层来完成。

**再有,分层之后可以做到很高的复用。**比如,我们在设计系统 A 的时候,发现某一层具有一定的通用性,那么我们可以把它抽取独立出来,在设计系统 B 的时候使用起来,这样可以减少研发周期,提升研发的效率。

**最后一点,分层架构可以让我们更容易做横向扩展。**如果系统没有分层,当流量增加时我们需要针对整体系统来做扩展。但是,如果我们按照上面提到的三层架构将系统分层后,那么我们就可以针对具体的问题来做细致的扩展。

比如说,业务逻辑里面包含有比较复杂的计算,导致 CPU 成为性能的瓶颈,那这样就可以把逻辑层单独抽取出来独立部署,然后只对逻辑层来做扩展,这相比于针对整体系统扩展所付出的代价就要小的多了。

这一点也可以解释我们课程开始时提出的问题:架构分层究竟和高并发设计的关系是怎样的?在第 1 节中中我们了解到,横向扩展是高并发系统设计的常用方法之一,既然分层的架构可以为横向扩展提供便捷, 那么支撑高并发的系统一定是分层的系统。

如何来做系统分层

说了这么多分层的优点,那么当我们要做分层设计的时候,需要考虑哪些关键因素呢?

在我看来,最主要的一点就是你需要理清楚 每个层次的边界是什么。你也许会问:如果按照三层架构来分层的话,每一层的边界不是很容易就界定吗?

没错,当业务逻辑简单时,层次之间的边界的确清晰,开发新的功能时也知道哪些代码要往哪儿写。但是当业务逻辑变得越来越复杂时,边界就会变得越来越模糊,给你举个例子。

任何一个系统中都有用户系统,最基本的接口是返回用户信息的接口,它调用逻辑层的 GetUser 方法,GetUser 方法又和 User DB 交互获取数据,就像下图左边展示的样子。

这时,产品提出一个需求,在 APP 中展示用户信息的时候,如果用户不存在,那么要自动给用户创建一个用户。同时,要做一个 HTML5 的页面,HTML5 页面要保留之前的逻辑,也就是不需要创建用户。这时逻辑层的边界就变得不清晰,表现层也承担了一部分的业务逻辑(将获取用户和创建用户接口编排起来)

NAME

那我们要如何做呢?我们可以将原先的三层架构细化成下面的样子:

NAME

我来解释一下这个分层架构中的每一层的作用:

  • 终端显示层:

    各端模板渲染并执行显示的层。当前主要是 Velocity 渲染,JS 渲染, JSP 渲染,移动端展示等。

  • 开放接口层:

    将 Service 层方法封装成开放接口,同时进行网关安全控制和流量控制等。

  • Web 层:

    主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。

  • Service 层:业务逻辑层。

  • Manager 层:

    通用业务处理层。这一层主要有两个作用:

    • 其一,你可以将原先 Service 层的一些通用能力下沉到这一层,比如 与缓存和存储交互策略,中间件的接入;
    • 其二,你也可以在这一层 封装对第三方接口的调用,比如调用支付服务,调用审核服务等
  • DAO 层:

    数据访问层,与底层 MySQL、Oracle、Hbase 等进行数据交互。

  • 外部接口或第三方平台:

    包括其它部门 RPC 开放接口,基础平台,其它公司的 HTTP 接口。

在这个分层架构中 主要增加了 Manager 层,它与 Service 层的关系是:Manager 层提供原子的服务接口,Service 层负责依据业务逻辑来编排原子接口

以上面的例子来说,Manager 层提供 创建用户获取用户信息 的接口,而 Service 层负责将这两个接口组装起来。这样就把原先散布在表现层的业务逻辑都统一到了 Service 层,每一层的边界就非常清晰了。

除此之外,分层架构需要考虑的另一个因素,是 层次之间一定是相邻层互相依赖数据的流转也只能在相邻的两层之间流转

我们还是以三层架构为例,数据从表示层进入之后一定要流转到逻辑层,做业务逻辑处理,然后流转到数据访问层来和数据库交互。那么你可能会问:如果业务逻辑很简单的话可不可以从表示层直接到数据访问层,甚至直接读数据库呢?

其实从功能上是可以的,但是从长远的架构设计考虑,这样会造成层级调用的混乱,比方说如果表示层或者业务层可以直接操作数据库,那么一旦数据库地址发生变更,你就需要在多个层次做更改,这样就失去了分层的意义,并且对于后面的维护或者重构都会是灾难性的。

分层架构的不足

任何事物都不可能是尽善尽美的,分层架构虽有优势也会有缺陷,它最主要的一个缺陷就是增加了代码的复杂度

这是显而易见的嘛,明明可以在接收到请求后就可以直接查询数据库获得结果,却偏偏要在中间插入多个层次,并且有可能每个层次只是简单地做数据的传递。有时增加一个小小的需求也需要更改所有层次上的代码,看起来增加了开发的成本,并且从调试上来看也增加了复杂度,原本如果直接访问数据库我只需要调试一个方法,现在我却要调试多个层次的多个方法。

另外一个可能的缺陷是,如果我们把每个层次独立部署,层次间通过网络来交互,那么多层的架构在性能上会有损耗。这也是为什么服务化架构性能要比单体架构略差的原因,也就是所谓的 多一跳 问题。

那我们是否要选择分层的架构呢?答案当然是肯定的。

你要知道,任何的方案架构都是有优势有缺陷的,天地尚且不全何况我们的架构呢?分层架构固然会增加系统复杂度,也可能会有性能的损耗,但是相比于它能带给我们的好处来说,这些都是可以接受的,或者可以通过其它的方案解决的。我们在做决策的时候切不可以偏概全,因噎废食。

课程小结

今天我带着你了解了分层架构的优势和不足,以及我们在实际工作中如何来对架构做分层。我想让你了解的是,分层架构是软件设计思想的外在体现,是一种实现方式。我们熟知的一些软件设计原则都在分层架构中有所体现。

比方说:

  • 单一职责原则 规定每个类只有单一的功能

    在这里可以引申为每一层拥有单一职责,且层与层之间边界清晰;

  • 迪米特法则 原意是一个对象应当对其它对象有尽可能少的了解

    在分层架构的体现是数据的交互不能跨层,只能在相邻层之间进行;

  • 开闭原则 要求软件对扩展开放,对修改关闭。

    它的含义其实就是将抽象层和实现层分离,抽象层是对实现层共有特征的归纳总结,不可以修改,但是具体的实现是可以无限扩展,随意替换的。

掌握这些设计思想会自然而然地明白分层架构设计的妙处,同时也能帮助我们做出更好的设计方案。

3 - CH03-基础-高性能

提到互联网系统设计,你可能听到最多的词儿就是 三高,也就是 高并发高性能高可用,它们是互联网系统架构设计永恒的主题。在前两节课中,我带你了解了高并发系统设计的含义,意义以及分层设计原则,接下来,我想带你整体了解一下高并发系统设计的目标,然后在此基础上,进入我们今天的话题:如何提升系统的性能?

高并发系统设计的三大目标:高性能、高可用、可扩展

高并发, 是指运用设计手段让系统能够处理更多的用户并发请求,也就是承担更大的流量。它是一切架构设计的背景和前提,脱离了它去谈性能和可用性是没有意义的。很显然嘛,你在每秒一次请求和每秒一万次请求,两种不同的场景下,分别做到毫秒级响应时间和五个九(99.999%)的可用性,无论是设计难度还是方案的复杂度,都不是一个级别的。

而性能和可用性, 是我们实现高并发系统设计必须考虑的因素。

性能反应了系统的使用体验,想象一下,同样承担每秒一万次请求的两个系统,一个响应时间是毫秒级,一个响应时间在秒级别,它们带给用户的体验肯定是不同的。

可用性则表示系统可以正常服务用户的时间。我们再类比一下,还是两个承担每秒一万次的系统,一个可以做到全年不停机、无故障,一个隔三差五宕机维护,如果你是用户,你会选择使用哪一个系统呢?答案不言而喻。

另一个耳熟能详的名词叫 可扩展性 ,它同样是高并发系统设计需要考虑的因素。为什么呢?我来举一个具体的例子。

流量分为 平时流量峰值流量 两种,峰值流量可能会是平时流量的几倍甚至几十倍,在应对峰值流量的时候,我们通常需要在架构和方案上做更多的准备。这就是淘宝会花费大半年的时间准备双十一,也是在面对「明星离婚」等热点事件时,看起来无懈可击的微博系统还是会出现服务不可用的原因。 而易于扩展的系统能在短时间内迅速完成扩容,更加平稳地承担峰值流量。

高性能、高可用和可扩展,是我们在做高并发系统设计时追求的三个目标,我会用三节课的时间,带你了解在高并发大流量下如何设计高性能、高可用和易于扩展的系统。

了解完这些内容之后,我们正式进入今天的话题:如何提升系统的性能?

性能优化原则)性能优化原则

「天下武功,唯快不破」。性能是系统设计成功与否的关键,实现高性能也是对程序员个人能力的挑战。不过在了解实现高性能的方法之前,我们先明确一下性能优化的原则。

  • 首先,性能优化一定不能盲目,一定是问题导向的

    脱离了问题,盲目地提早优化会增加系统的复杂度,浪费开发人员的时间,也因为某些优化可能会对业务上有些折中的考虑,所以也会损伤业务。

  • 其次,性能优化也遵循「八二原则」

    即你可以用 20% 的精力解决 80% 的性能问题。所以我们在优化过程中一定要抓住主要矛盾,优先优化主要的性能瓶颈点。

  • 再次,性能优化也要有数据支撑

    在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。

  • 最后,性能优化的过程是持续的

    高并发的系统通常是业务逻辑相对复杂的系统,那么在这类系统中出现的性能问题通常也会有多方面的原因。因此,我们在做性能优化的时候要明确目标,比方说,支撑每秒 1 万次请求的吞吐量下响应时间在 10ms,那么我们就需要持续不断地寻找性能瓶颈,制定优化方案,直到达到目标为止。

在以上四个原则的指引下,掌握常见性能问题的排查方式和优化手段,就一定能让你在设计高并发系统时更加游刃有余。

性能的度量指标)性能的度量指标

性能优化的第三点原则中提到,对于性能我们需要有度量的标准,有了数据才能明确目前存在的性能问题,也能够用数据来评估性能优化的效果。 所以明确性能的度量指标十分重要。

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

  • 平均值

    顾名思义,平均值是把这段时间所有请求的响应时间数据相加,再除以总请求数。平均值可以在一定程度上反应这段时间的性能,但它敏感度比较差,如果这段时间有少量慢请求时,在平均值上并不能如实的反应。

    举个例子,假设我们在 30s 内有 10000 次请求,每次请求的响应时间都是 1ms,那么这段时间响应时间平均值也是 1ms。这时,当其中 100 次请求的响应时间变成了 100ms,那么整体的响应时间是 100 * 100 + 9900 * 1) / 10000 = 1.99ms。你看,虽然从平均值上来看仅仅增加了不到 1ms,但是实际情况是有 1% 的请求(100/10000) 的响应时间已经增加了 100 倍。 所以,平均值对于度量性能来说只能作为一个参考。

  • 最大值

    这个更好理解,就是这段时间内所有请求响应时间最长的值,但它的问题又在于过于敏感了

    还拿上面的例子来说,如果 10000 次请求中只有一次请求的响应时间达到 100ms,那么这段时间请求的响应耗时的最大值就是 100ms,性能损耗为原先的百分之一,这种说法明显是不准确的。

  • 分位值

    分位值有很多种,比如 90 分位、95 分位、75 分位。以 90 分位为例,我们把这段时间请求的 响应时间从小到大排序,假如一共有 100 个请求,那么排在第 90 位的响应时间就是 90 分位值。分位值排除了偶发极慢请求对于数据的影响,能够很好地反应这段时间的性能情况,分位值越大,对于慢请求的影响就越敏感

NAME

在我来看,分位值是最适合作为时间段内,响应时间统计值来使用的,在实际工作中也应用最多。除此之外,平均值也可以作为一个参考值来使用。

我在上面提到,脱离了并发来谈性能是没有意义的,我们通常使用 吞吐量 或者 同时在线用户数 来度量并发和流量,使用吞吐量的情况会更多一些。但是你要知道,这两个指标是呈倒数关系的。

这很好理解,响应时间 1s 时,吞吐量是每秒 1 次,响应时间缩短到 10ms,那么吞吐量就上升到每秒 100 次。所以,一般我们度量性能时都会同时兼顾吞吐量和响应时间,比如我们设立性能优化的目标时通常会这样表述:在每秒 1 万次的请求量下,响应时间 99 分位值在 10ms 以下。

那么,响应时间究竟控制在多长时间比较合适呢?这个不能一概而论。

从用户使用体验的角度来看:

  • 200ms 是第一个分界点:

    接口的响应时间在 200ms 之内,用户是感觉不到延迟的,就像是瞬时发生的一样。

  • 而 1s 是另外一个分界点:

    接口的响应时间在 1s 之内时,虽然用户可以感受到一些延迟,但却是可以接受的

  • 超过 1s 之后用户就会有明显等待的感觉,等待时间越长,用户的使用体验就越差。

所以,健康系统的 99 分位值的响应时间通常需要控制在 200ms 之内,而不超过 1s 的请求占比要在 99.99% 以上。

现在你了解了性能的度量指标,那我们再来看一看,随着并发的增长我们实现高性能的思路是怎样的。

高并发下的性能优化)高并发下的性能优化

假如说,你现在有一个系统,这个系统中处理核心只有一个,执行的任务的响应时间都在 10ms,它的吞吐量是在每秒 100 次。那么我们如何来优化性能从而提高系统的并发能力呢?主要有两种思路:

  • 一种是提高系统的处理核心数
  • 另一种是减少单次任务的响应时间。

1. 提高系统的处理核心数

提高系统的处理核心数就是 增加系统的并行处理能力,这个思路是优化性能最简单的途径。拿上一个例子来说,你可以把系统的处理核心数增加为两个,并且增加一个进程,让这两个进程跑在不同的核心上。这样从理论上,你系统的吞吐量可以增加一倍。当然了,在这种情况下,吞吐量和响应时间就不是倒数关系了,而是:吞吐量 = 并发进程数 / 响应时间

计算机领域的阿姆达尔定律(Amdahl’s law)是吉恩·阿姆达尔在 1967 年提出的。它描述了并发进程数与响应时间之间的关系,含义是在固定负载下,并行计算的加速比,也就是并行化之后效率提升情况,可以用下面公式来表示:(Ws + Wp) / (Ws + Wp/s)

其中,Ws 表示任务中的串行计算量,Wp 表示任务中的并行计算量,s 表示并行进程数。从这个公式我们可以推导出另外一个公式:1/(1-p+p/s)

其中,s 还是表示并行进程数,p 表示任务中并行部分的占比。当 p 为 1 时,也就是完全并行时,加速比与并行进程数相等;当 p 为 0 时,即完全串行时,加速比为 1,也就是说完全无加速;当 s 趋近于无穷大的时候,加速比就等于 1/(1-p),你可以看到它完全和 p 成正比。特别是,当 p 为 1 时,加速比趋近于无穷大。

以上公式的推导过程有些复杂,你只需要记住结论就好了。

我们似乎找到了解决问题的银弹,**是不是无限制地增加处理核心数就能无限制地提升性能,从而提升系统处理高并发的能力呢?**很遗憾,随着并发进程数的增加,并行的任务对于系统资源的争抢也会愈发严重。在某一个临界点上继续增加并发进程数,反而会造成系统性能的下降,这就是性能测试中的 拐点模型

NAME

从图中你可以发现:

  • 并发用户数处于轻压力区时,响应时间平稳,吞吐量和并发用户数线性相关。
  • 而当并发用户数处于重压力区时,系统资源利用率到达极限,吞吐量开始有下降的趋势,响应时间也会略有上升。
  • 这个时候,再对系统增加压力,系统就进入拐点区,处于超负荷状态,吞吐量下降,响应时间大幅度上升。

所以我们在评估系统性能时通常需要做压力测试,目的就是找到系统的「拐点」,从而知道系统的承载能力,也便于找到系统的瓶颈,持续优化系统性能。

说完了提升并行能力,我们再看看优化性能的另一种方式:减少单次任务响应时间。

2. 减少单次任务响应时间

想要减少任务的响应时间,首先要看你的系统是 CPU 密集型 还是 IO 密集型 的,因为不同类型的系统性能优化方式不尽相同。

CPU 密集型系统

CPU 密集型系统中,需要处理大量的 CPU 运算,那么选用更高效的算法或者减少运算次数就是这类系统重要的优化手段。比方说,如果系统的主要任务是计算 Hash 值,那么这时选用更高性能的 Hash 算法就可以大大提升系统的性能。发现这类问题的主要方式,是通过一些 Profile 工具来找到消耗 CPU 时间最多的方法或者模块,比如 Linux 的 perf、eBPF 等。

IO 密集型系统

IO 密集型系统指的是系统的大部分操作是在等待 IO 完成,这里 IO 指的是磁盘 IO 和网络 IO。我们熟知的系统大部分都属于 IO 密集型,比如数据库系统、缓存系统、Web 系统。这类系统的性能瓶颈可能出在系统内部,也可能是依赖的其他系统,而发现这类性能瓶颈的手段主要有两类。

  • 第一类是 采用工具

    Linux 的工具集很丰富,完全可以满足你的优化需要,比如网络协议栈、网卡、磁盘、文件系统、内存,等等。这些工具的用法很多,你可以在排查问题的过程中逐渐积累。除此之外呢,一些开发语言还有针对语言特性的分析工具,比如说 Java 语言就有其专属的内存分析工具。

  • 另外一类手段就是可以通过 监控 来发现性能问题。

    在监控中我们可以对任务的每一个步骤做分时的统计,从而找到任务的哪一步消耗了更多的时间。这一部分在演进篇中会有专门的介绍,这里就不再展开了。

那么找到了系统的瓶颈点,我们要如何优化呢?优化方案会随着问题的不同而不同。比方说,如果是数据库访问慢,那么就要看是不是有锁表的情况、是不是有全表扫描、索引加得是否合适、是否有 JOIN 操作、需不需要加缓存,等等;如果是网络的问题,就要看网络的参数是否有优化的空间,抓包来看是否有大量的超时重传,网卡是否有大量丢包等。

总而言之,「兵来将挡水来土掩」,我们需要制定不同的性能优化方案来应对不同的性能问题。

课程小结

今天,我带你了解了性能的原则、度量指标,以及在高并发下优化性能的基本思路。性能优化是一个很大的话题,只用短短一讲是完全不够的,所以我会在后面的课程中详细介绍其中的某些方面,比方说我们如何用缓存优化系统的读取性能,如何使用消息队列优化系统的写入性能等等。

有时候你在遇到性能问题的时候会束手无策,从今天的课程中你可以得到一些启示,在这里我给你总结出几点:

  • 数据优先,你做一个新的系统在上线之前一定要把性能监控系统做好;
  • 掌握一些性能优化工具和方法,这就需要在工作中不断的积累;
  • 计算机基础知识很重要,比如说网络知识、操作系统知识等等,掌握了基础知识才能让你在优化过程中抓住性能问题的关键,也能在性能优化过程中游刃有余。

4 - CH04-基础-高可用

本节课,我会继续带你了解高并发系统设计的第二个目标——高可用性。你需要在本节课对提升系统可用性的思路和方法有一个直观的了解,这样,当后续对点讲解这些内容时,你能马上反应过来,你的系统在遇到可用性的问题时,也能参考这些方法进行优化。

高可用性(High Availability,HA) 是你在系统设计时经常会听到的一个名词,它指的是系统具备较高的无故障运行的能力

我们在很多开源组件的文档中看到的 HA 方案就是提升组件可用性,让系统免于宕机无法服务的方案。比如,你知道 Hadoop 1.0 中的 NameNode 是单点的,一旦发生故障则整个集群就会不可用;而在 Hadoop2 中提出的 NameNode HA 方案就是同时启动两个 NameNode,一个处于 Active 状态,另一个处于 Standby 状态,两者共享存储,一旦 Active NameNode 发生故障,则可以将 Standby NameNode 切换成 Active 状态继续提供服务,这样就增强了 Hadoop 的持续无故障运行的能力,也就是提升了它的可用性。

通常来讲,一个高并发大流量的系统,系统出现故障比系统性能低更损伤用户的使用体验。想象一下,一个日活用户过百万的系统,一分钟的故障可能会影响到上千的用户。而且随着系统日活的增加,一分钟的故障时间影响到的用户数也随之增加,系统对于可用性的要求也会更高。所以今天,我就带你了解一下在高并发下,我们如何来保证系统的高可用性,以便给你的系统设计提供一些思路。

可用性的度量

可用性是一个抽象的概念,你需要知道要如何来度量它,与之相关的概念是: MTBF 和 MTTR。

MTBF(Mean Time Between Failure) 是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。

MTTR(Mean Time To Repair) 表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。

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

Availability = MTBF / (MTBF + MTTR)

这个公式计算出的结果是一个比例,而这个比例代表着系统的可用性。一般来说,我们会使用几个九来描述系统的可用性。

NAME

其实通过这张图你可以发现,一个九和两个九的可用性是很容易达到的,只要没有蓝翔技校的铲车搞破坏,基本上可以通过人肉运维的方式实现。

三个九之后,系统的年故障时间从 3 天锐减到 8 小时。到了四个九之后,年故障时间缩减到 1 小时之内。在这个级别的可用性下,你可能需要建立完善的运维值班体系、故障处理流程和业务变更流程。你可能还需要在系统设计上有更多的考虑。比如,在开发中你要考虑,如果发生故障,是否不用人工介入就能自动恢复。当然了,在工具建设方面,你也需要多加完善,以便快速排查故障原因,让系统快速恢复。

到达五个九之后,故障就不能靠人力恢复了。想象一下,从故障发生到你接收报警,再到你打开电脑登录服务器处理问题,时间可能早就过了十分钟了。所以这个级别的可用性考察的是系统的容灾和自动恢复的能力,让机器来处理故障,才会让可用性指标提升一个档次。

一般来说,我们的核心业务系统的可用性,需要达到四个九,非核心系统的可用性最多容忍到三个九。在实际工作中,你可能听到过类似的说法,只是不同级别,不同业务场景的系统对于可用性要求是不一样的。

目前,你已经对可用性的评估指标有了一定程度的了解了,接下来,我们来看一看高可用的系统设计需要考虑哪些因素。

高可用系统设计的思路

一个成熟系统的可用性需要从系统设计和系统运维两方面来做保障,两者共同作用,缺一不可。那么如何从这两方面入手,解决系统高可用的问题呢?

1. 系统设计

Design for failure 是我们做高可用系统设计时秉持的第一原则。在承担百万 QPS 的高并发系统中,集群中机器的数量成百上千台,单机的故障是常态,几乎每一天都有发生故障的可能。

未雨绸缪才能决胜千里。我们在做系统设计的时候,要把发生故障作为一个重要的考虑点,预先考虑如何自动化地发现故障,发生故障之后要如何解决。当然了,除了要有未雨绸缪的思维之外,我们还需要掌握一些具体的优化方法,比如 failover(故障转移)、超时控制以及降级和限流。

failover(故障转移)

一般来说,发生 failover 的节点可能有两种情况:

  1. 是在 完全对等 的节点之间做 failover。
  2. 是在 不对等 的节点之间,即系统中存在主节点也存在备节点。

在对等节点之间做 failover 相对来说简单些。在这类系统中所有节点都承担读写流量,并且节点中不保存状态,每个节点都可以作为另一个节点的镜像。在这种情况下,如果访问某一个节点失败,那么简单地随机访问另一个节点就好了。

举个例子,Nginx 可以配置当某一个 Tomcat 出现大于 500 的请求的时候,重试请求另一个 Tomcat 节点,就像下面这样:

NAME

针对不对等节点的 failover 机制会复杂很多。比方说我们有一个主节点,有多台备用节点,这些备用节点可以是热备(同样在线提供服务的备用节点),也可以是冷备(只作为备份使用),那么我们就需要在代码中控制如何检测主备机器是否故障,以及如何做主备切换。

使用最广泛的故障检测机制是「心跳」。你可以在客户端上定期地向主节点发送心跳包,也可以从备份节点上定期发送心跳包。当一段时间内未收到心跳包,就可以认为主节点已经发生故障,可以触发选主的操作。

选主的结果需要在多个备份节点上达成一致,所以会使用某一种分布式一致性算法,比方说 Paxos,Raft。

调用超时控制

除了故障转移以外,对于系统间调用超时的控制也是高可用系统设计的一个重要考虑方面。

复杂的高并发系统通常会有很多的系统模块组成,同时也会依赖很多的组件和服务,比如说缓存组件,队列服务等等。它们之间的调用最怕的就是延迟而非失败 ,因为失败通常是瞬时的,可以通过重试的方式解决。而一旦调用某一个模块或者服务发生比较大的延迟,调用方就会阻塞在这次调用上,它已经占用的资源得不到释放。当存在大量这种阻塞请求时,调用方就会因为用尽资源而挂掉。

在系统开发的初期,超时控制通常不被重视,或者是没有方式来确定正确的超时时间。

我之前经历过一个项目, 模块之间通过 RPC 框架来调用,超时时间是默认的 30 秒。平时系统运行得非常稳定,可是一旦遇到比较大的流量,RPC 服务端出现一定数量慢请求的时候,RPC 客户端线程就会大量阻塞在这些慢请求上长达 30 秒,造成 RPC 客户端用尽调用线程而挂掉。后面我们在故障复盘的时候发现这个问题后,调整了 RPC,数据库,缓存以及调用第三方服务的超时时间,这样在出现慢请求的时候可以触发超时,就不会造成整体系统雪崩。

既然要做超时控制,那么我们怎么来确定超时时间呢?这是一个比较困难的问题。

超时时间短了,会造成大量的超时错误,对用户体验产生影响;超时时间长了,又起不到作用。 我建议你通过收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。 如果没有调用的日志,那么你只能按照经验值来指定超时时间。不过,无论你使用哪种方式,超时时间都不是一成不变的,需要在后面的系统维护过程中不断地修改。

超时控制实际上就是不让请求一直保持,而是在经过一定时间之后让请求失败,释放资源给接下来的请求使用。这对于用户来说是有损的,但是却是必要的,因为它牺牲了少量的请求却保证了整体系统的可用性。而我们还有另外两种有损的方案能保证系统的高可用,它们就是降级和限流。

降级

降级是为了保证核心服务的稳定而牺牲非核心服务的做法。 比方说我们发一条微博会先经过反垃圾服务检测,检测内容是否是广告,通过后才会完成诸如写数据库等逻辑。

反垃圾的检测是一个相对比较重的操作,因为涉及到非常多的策略匹配,在日常流量下虽然会比较耗时却还能正常响应。但是当并发较高的情况下,它就有可能成为瓶颈,而且它也不是发布微博的主体流程,所以我们可以暂时关闭反垃圾服务检测,这样就可以保证主体的流程更加稳定。

限流

限流完全是另外一种思路,它通过对并发的请求进行限速来保护系统。

比如对于 Web 应用,我限制单机只能处理每秒 1000 次的请求,超过的部分直接返回错误给客户端。虽然这种做法损害了用户的使用体验,但是它是在极端并发下的无奈之举,是短暂的行为,因此是可以接受的。

实际上,无论是降级还是限流,在细节上还有很多可供探讨的地方,我会在后面的课程中,随着系统的不断演进深入地剖析,在基础篇里就不多说了。

2. 系统运维

在系统设计阶段为了保证系统的可用性可以采取上面的几种方法,那在系统运维的层面又能做哪些事情呢?其实,我们可以从 灰度发布、故障演练 两个方面来考虑如何提升系统的可用性。

你应该知道,在业务平稳运行过程中,系统是很少发生故障的,90% 的故障是发生在上线变更阶段的。比方说,你上了一个新的功能,由于设计方案的问题,数据库的慢请求数翻了一倍,导致系统请求被拖慢而产生故障。

如果没有变更,数据库怎么会无缘无故地产生那么多的慢请求呢?因此,为了提升系统的可用性,重视变更管理尤为重要。而除了提供必要回滚方案,以便在出现问题时快速回滚恢复之外, 另一个主要的手段就是灰度发布。

灰度发布

灰度发布指的是系统的变更不是一次性地推到线上的,而是按照一定比例逐步推进的。一般情况下,灰度发布是以机器维度进行的。比方说,我们先在 10% 的机器上进行变更,同时观察 Dashboard 上的系统性能指标以及错误日志。如果运行了一段时间之后系统指标比较平稳并且没有出现大量的错误日志,那么再推动全量变更。

灰度发布给了开发和运维同学绝佳的机会,让他们能在线上流量上观察变更带来的影响,是保证系统高可用的重要关卡。

灰度发布是在系统正常运行条件下,保证系统高可用的运维手段,那么我们如何知道发生故障时系统的表现呢?这里就要依靠另外一个手段: 故障演练。

故障演练

故障演练指的是对系统进行一些破坏性的手段,观察在出现局部故障时,整体的系统表现是怎样的,从而发现系统中存在的,潜在的可用性问题。

一个复杂的高并发系统依赖了太多的组件,比方说磁盘,数据库,网卡等,这些组件随时随地都可能会发生故障,而一旦它们发生故障,会不会如蝴蝶效应一般造成整体服务不可用呢?我们并不知道,因此,故障演练尤为重要。

在我来看, 故障演练和时下比较流行的“混沌工程”的思路如出一辙, 作为混沌工程的鼻祖,Netfix 在 2010 年推出的 Chaos Monkey 工具就是故障演练绝佳的工具。它通过在线上系统上随机地关闭线上节点来模拟故障,让工程师可以了解,在出现此类故障时会有什么样的影响。

当然,这一切是以你的系统可以抵御一些异常情况为前提的。如果你的系统还没有做到这一点,那么 我建议你 另外搭建一套和线上部署结构一模一样的线下系统,然后在这套系统上做故障演练,从而避免对生产系统造成影响。

课程小结

本节课我带你了解了如何度量系统的可用性,以及在做高并发系统设计时如何来保证高可用。

说了这么多,你可以看到从开发和运维角度上来看,提升可用性的方法是不同的:

  • 开发 注重的是如何处理故障,关键词是 冗余和取舍

    冗余指的是有备用节点,集群来顶替出故障的服务,比如文中提到的故障转移,还有多活架构等等;取舍指的是丢卒保车,保障主体服务的安全。

  • 运维角度 来看则更偏保守,注重的是如何避免故障的发生

    比如更关注变更管理以及如何做故障的演练。

两者结合起来才能组成一套完善的高可用体系。

你还需要注意的是,提高系统的可用性有时候是以牺牲用户体验或者是牺牲系统性能为前提的,也需要大量人力来建设相应的系统,完善机制。所以我们要把握一个度,不该做过度的优化。就像我在文中提到的,核心系统四个九的可用性已经可以满足需求,就没有必要一味地追求五个九甚至六个九的可用性。

另外,一般的系统或者组件都是追求极致的性能的,那么有没有不追求性能,只追求极致的可用性的呢?答案是有的。比如配置下发的系统,它只需要在其它系统启动时提供一份配置即可,所以秒级返回也可,十秒钟也 OK,无非就是增加了其它系统的启动速度而已。但是,它对可用性的要求是极高的,甚至会到六个九,原因是配置可以获取的慢,但是不能获取不到。我给你举这个例子是想让你了解, 可用性和性能有时候是需要做取舍的,但如何取舍就要视不同的系统而定,不能一概而论了。

5 - CH05-基础-易扩展

从架构设计上来说,高可扩展性是一个设计的指标,它表示可以通过增加机器的方式来线性提高系统的处理能力,从而承担更高的流量和并发

你可能会问:在架构设计之初,为什么不预先考虑好使用多少台机器,支持现有的并发呢?这个问题我在第 3 节中提到过,答案 是峰值的流量不可控

一般来说,基于成本考虑,在业务平稳期,我们会预留 30%~50% 的冗余以应对运营活动或者推广可能带来的峰值流量,但是当有一个突发事件发生时,流量可能瞬间提升到 2~3 倍甚至更高,我们还是以微博为例。

鹿晗和关晓彤互圈公布恋情,大家会到两个人的微博下面,或围观,或互动,微博的流量短时间内增长迅速,微博信息流也短暂出现无法刷出新的消息的情况。

那我们要如何应对突发的流量呢?架构的改造已经来不及了,最快的方式就是堆机器。不过我们需要保证,扩容了三倍的机器之后,相应的我们的系统也能支撑三倍的流量。有的人可能会产生疑问:这不是显而易见的吗?很简单啊。真的是这样吗?我们来看看做这件事儿难在哪儿。

为什么提升扩展性会很复杂

在上一讲中,我提到可以在单机系统中通过增加处理核心的方式,来增加系统的并行处理能力,但这个方式并不总生效。因为当并行的任务数较多时,系统会因为争抢资源而达到性能上的拐点,系统处理能力不升反降。

而对于由多台机器组成的集群系统来说也是如此。集群系统中,不同的系统分层上可能存在一些 「瓶颈点」,这些瓶颈点制约着系统的横线扩展能力。这句话比较抽象,我举个例子你就明白了。

比方说,你系统的流量是每秒 1000 次请求,对数据库的请求量也是每秒 1000 次。如果流量增加 10 倍,虽然系统可以通过扩容正常服务,数据库却成了瓶颈。再比方说,单机网络带宽是 50Mbps,那么如果扩容到 30 台机器,前端负载均衡的带宽就超过了千兆带宽的限制,也会成为瓶颈点。那么,我们的系统中存在哪些服务会成为制约系统扩展的重要因素呢?

其实,无状态的服务和组件更易于扩展,而像 MySQL 这种存储服务是有状态的,就比较难以扩展。因为向存储集群中增加或者减少机器时,会涉及大量数据的迁移,而一般传统的关系型数据库都不支持。这就是为什么提升系统扩展性会很复杂的主要原因。

除此之外,从例子中你可以看到,我们需要站在整体架构的角度,而不仅仅是业务服务器的角度来考虑系统的扩展性 。 所以说,数据库、缓存、依赖的第三方、负载均衡、交换机带宽等等 都是系统扩展时需要考虑的因素。我们要知道系统并发到了某一个量级之后,哪一个因素会成为我们的瓶颈点,从而针对性地进行扩展。

针对这些复杂的扩展性问题,我提炼了一些系统设计思路,供你了解。

高可扩展性的设计思路

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

但对于不同类型的模块,我们在拆分上遵循的原则是不一样的。我给你举一个简单的例子,假如你要设计一个社区,那么社区会有几个模块呢?可能有 5 个模块。

  • 用户:负责维护社区用户信息,注册,登陆等;
  • 关系:用户之间关注、好友、拉黑等关系的维护;
  • 内容:社区发的内容,就像朋友圈或者微博的内容;
  • 评论、赞:用户可能会有的两种常规互动操作;
  • 搜索:用户的搜索,内容的搜索。

而部署方式遵照最简单的三层部署架构,负载均衡负责请求的分发,应用服务器负责业务逻辑的处理,数据库负责数据的存储落地。这时,所有模块的业务代码都混合在一起了,数据也都存储在一个库里。

NAME

1. 存储层的扩展性

无论是存储的数据量,还是并发访问量,不同的业务模块之间的量级相差很大,比如说成熟社区中,关系的数据量是远远大于用户数据量的,但是用户数据的访问量却远比关系数据要大。所以假如存储目前的瓶颈点是容量,那么我们只需要针对关系模块的数据做拆分就好了,而不需要拆分用户模块的数据。 所以存储拆分首先考虑的维度是业务维度。

拆分之后,这个简单的社区系统就有了用户库、内容库、评论库、点赞库和关系库。这么做还能隔离故障,某一个库「挂了」不会影响到其它的数据库。

NAME

按照业务拆分,在一定程度上提升了系统的扩展性,但系统运行时间长了之后,单一的业务数据库在容量和并发请求量上仍然会超过单机的限制。 这时,我们就需要针对数据库做第二次拆分。

这次拆分是按照数据特征做水平的拆分 ,比如说我们可以给用户库增加两个节点,然后按照某些算法将用户的数据拆分到这三个库里面,具体的算法我会在后面讲述数据库分库分表时和你细说。

水平拆分之后,我们就可以让数据库突破单机的限制了。但这里要注意,我们不能随意地增加节点,因为一旦增加节点就需要手动地迁移数据,成本还是很高的。所以基于长远的考虑,我们最好一次性增加足够的节点以避免频繁地扩容

当数据库按照业务和数据维度拆分之后,我们 尽量不要使用事务。因为当一个事务中同时更新不同的数据库时,需要使用二阶段提交,来协调所有数据库要么全部更新成功,要么全部更新失败。这个协调的成本会随着资源的扩展不断升高,最终达到无法承受的程度。

说完了存储层的扩展性,我们来看看业务层是如何做到易于扩展的。

2. 业务层的扩展性

我们一般会从三个维度考虑业务层的拆分方案,它们分别是:业务纬度重要性纬度请求来源纬度

首先,我们需要把相同业务的服务拆分成单独的业务池,比方说上面的社区系统中,我们可以按照业务的维度拆分成用户池、内容池、关系池、评论池、点赞池和搜索池。

每个业务依赖独自的数据库资源,不会依赖其它业务的数据库资源。这样当某一个业务的接口成为瓶颈时,我们只需要扩展业务的池子,以及确认上下游的依赖方就可以了,这样就大大减少了扩容的复杂度。

NAME

除此之外,我们还可以根据业务接口的重要程度,把业务分为核心池和非核心池 (池子就是一组机器组成的集群) 。打个比方,就关系池而言,关注、取消关注接口相对重要一些,可以放在核心池里面;拉黑和取消拉黑的操作就相对不那么重要,可以放在非核心池里面。这样,我们可以优先保证核心池的性能,当整体流量上升时优先扩容核心池,降级部分非核心池的接口,从而保证整体系统的稳定性。

NAME

最后,你还可以根据接入客户端类型的不同做业务池的拆分。比如说,服务于客户端接口的业务可以定义为外网池,服务于小程序或者 HTML5 页面的业务可以定义为 H5 池,服务于内部其它部门的业务可以定义为内网池,等等。

课程小结

本节课我带你了解了提升系统扩展性的复杂度以及系统拆分的思路。拆分看起来比较简单,可是什么时候做拆分,如何做拆分还是有很多细节考虑的。

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

6 - CH07-数据库-池化

来想象这样一个场景,一天,公司 CEO 把你叫到会议室,告诉你公司看到了一个新的商业机会,希望你能带领一名兄弟,迅速研发出一套面向某个垂直领域的电商系统。

在人手紧张,时间不足的情况下,为了能够完成任务,你毫不犹豫地采用了 最简单的架构 :前端一台 Web 服务器运行业务代码,后端一台数据库服务器存储业务数据。

NAME

这个架构图是我们每个人最熟悉的,最简单的架构原型,很多系统在一开始都是长这样的,只是随着业务复杂度的提高,架构做了叠加,然后看起来就越来越复杂了。

再说回我们的垂直电商系统,系统一开始上线之后,虽然用户量不大,但运行平稳,你很有成就感,不过 CEO 觉得用户量太少了,所以紧急调动运营同学做了一次全网的流量推广。

这一推广很快带来了一大波流量, 但这时,系统的访问速度开始变慢。

分析程序的日志之后,你发现系统慢的原因 出现在和数据库的交互上 。因为你们数据库的调用方式是先获取数据库的连接,然后依靠这条连接从数据库中查询数据,最后关闭连接释放数据库资源。这种调用方式下,每次执行 SQL 都需要重新建立连接,所以你怀疑, 是不是频繁地建立数据库连接耗费时间长导致了访问慢的问题

那么为什么频繁创建连接会造成响应时间慢呢?来看一个实际的测试。

我用

# -i: 指定网卡
tcpdump -i bond0 -nn -tttt port 4490

命令抓取了线上 MySQL 建立连接的网络包来做分析,从抓包结果来看,整个 MySQL 的连接过程可以分为两部分:

NAME
  • 第一部分是前三个数据包

    第一个数据包是客户端向服务端发送的一个 SYN 包,

    第二个包是服务端回给客户端的 ACK 包以及一个 SYN 包,

    第三个包是客户端回给服务端的 ACK 包,熟悉 TCP 协议的同学可以看出这是一个 TCP 的三次握手过程。

  • 第二部分是 MySQL 服务端校验客户端密码的过程。

    其中第一个包是服务端发给客户端要求认证的报文,

    第二和第三个包是客户端将加密后的密码发送给服务端的包,

    最后两个包是服务端回给客户端认证 OK 的报文。

从图中,你可以看到整个连接过程大概消耗了 4ms(969012-964904)。

那么单条 SQL 执行时间是多少呢?我们统计了一段时间的 SQL 执行时间,发现 SQL 的平均执行时间大概是 1ms,也就是说相比于 SQL 的执行,MySQL 建立连接的过程是比较耗时的。这在请求量小的时候其实影响不大,因为无论是建立连接还是执行 SQL,耗时都是毫秒级别的。可是请求量上来之后,如果按照原来的方式建立一次连接只执行一条 SQL 的话,1s 只能执行 200 次数据库的查询,而数据库建立连接的时间占了其中 4/5。

那这时你要怎么做呢?

一番谷歌搜索之后,你发现解决方案也很简单,只要使用连接池将数据库连接预先建立好,这样在使用的时候就不需要频繁地创建连接了。调整之后,你发现 1s 就可以执行 1000 次的数据库查询,查询性能大大的提升了。

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

虽然短时间解决了问题,不过你还是想彻底搞明白解决问题的核心原理,于是又开始补课。

其实,在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心, 我就以数据库连接池为例,来说明一下连接池管理的关键点。

数据库连接池有两个最重要的配置: 最小连接数和最大连接数, 它们控制着从连接池中获取连接的流程:

  • 如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
  • 如果连接池中有空闲连接则复用空闲连接;
  • 如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
  • 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;
  • 如果等待超过了这个设定时间则向用户抛出错误。

这个流程你不用死记,非常简单。你可以停下来想想如果你是连接池的设计者你会怎么设计,有哪些关键点,这个设计思路在我们以后的架构设计中经常会用到。

为了方便你理解性记忆这个流程,我来举个例子。

假设你在机场里经营着一家按摩椅的小店,店里一共摆着 10 台按摩椅(类比最大连接数),为了节省成本(按摩椅费电),你平时会保持店里开着 4 台按摩椅(最小连接数),其他 6 台都关着。

有顾客来的时候,如果平时保持启动的 4 台按摩椅有空着的,你直接请他去空着的那台就好了。但如果顾客来的时候,4 台按摩椅都不空着,那你就会新启动一台,直到你的 10 台按摩椅都被用完。

那 10 台按摩椅都被用完之后怎么办呢?你会告诉用户,稍等一会儿,我承诺你 5 分钟(等待时间)之内必定能空出来,然后第 11 位用户就开始等着。这时,会有两个结果:如果 5 分钟之内有空出来的,那顾客直接去空出来的那台按摩椅就可以了,但如果用户等了 5 分钟都没空出来,那你就得赔礼道歉,让用户去其他店再看看。

对于数据库连接池,根据我的经验,一般在线上我建议最小连接数控制在 10 左右,最大连接数控制在 20~30 左右即可。

在这里,你需要注意池子中连接的维护问题,也就是我提到的按摩椅。有的按摩椅虽然开着,但有的时候会有故障,一般情况下, 按摩椅故障 的原因可能有以下几种:

  1. 数据库的域名对应的 IP 发生了变更,池子的连接还是使用旧的 IP,当旧的 IP 下的数据库服务关闭后,再使用这个连接查询就会发生错误;
  2. MySQL 有个参数是 wait_timeout,控制着当数据库连接闲置多长时间后,数据库会主动的关闭这条连接。这个机制对于数据库使用方是无感知的,所以当我们使用这个被关闭的连接时就会发生错误。

那么,作为按摩椅店老板,你怎么保证你启动着的按摩椅一定是可用的呢?

  1. 启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送 select 1 的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用, 也是我比较推荐的方式。
  2. 在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引入多余的开销, 在线上系统中还是尽量不要开启,在测试服务上可以使用。

至此,你彻底搞清楚了连接池的工作原理。可是,当你刚想松一口气的时候,CEO 又提出了一个新的需求。你分析了一下这个需求,发现在一个非常重要的接口中,你需要访问 3 次数据库。根据经验判断,你觉得这里未来肯定会成为系统瓶颈。

进一步想,你觉得可以创建多个线程来并行处理与数据库之间的交互,这样速度就能快了。不过,因为有了上次数据库的教训,你想到在高并发阶段,频繁创建线程的开销也会很大,于是顺着之前的思路继续想,猜测到了线程池。

用线程池预先创建线程

果不其然,JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要的参数:coreThreadCountmaxThreadCount,这两个参数控制着线程池的执行过程。它的执行原理类似上面我们说的按摩椅店的模式,我这里再给你描述下,以加深你的记忆:

  • 如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程;
  • 如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执行;
  • 当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount;
  • 当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃了。
NAME

这个任务处理流程看似简单,实际上有很多坑,你在使用的时候一定要注意。

首先, JDK 实现的这个线程池优 先把任务放入队列暂存起来,而不是创建更多的线程 ,它比较适用于执行 CPU 密集型的任务,也就是需要执行大量 CPU 运算的任务。这是为什么呢?因为执行 CPU 密集型的任务时 CPU 比较繁忙,因此只需要创建和 CPU 核数相当的线程就好了,多了反而会造成线程上下文切换,降低任务执行效率。所以当当前线程数超过核心线程数时,线程池不会增加线程,而是放在队列里等待核心线程空闲下来。

但是,我们平时开发的 Web 系统通常都有大量的 IO 操作,比方说查询数据库、查询缓存等等。任务在执行 IO 操作的时候 CPU 就空闲了下来,这时如果增加执行任务的线程数而不是把任务暂存在队列中,就可以在单位时间内执行更多的任务,大大提高了任务执行的吞吐量。所以你看 Tomcat 使用的线程池就不是 JDK 原生的线程池,而是做了一些改造,当线程数超过 coreThreadCount 之后会优先创建线程,直到线程数到达 maxThreadCount,这样就比较适合于 Web 系统大量 IO 操作的场景了,你在实际运用过程中也可以参考借鉴。

其次,线程池中使用的队列的堆积量也是我们需要监控的重要指标 ,对于实时性要求比较高的任务来说,这个指标尤为关键。

我在实际项目中就曾经遇到过任务被丢给线程池之后,长时间都没有被执行的诡异问题。 最初,我认为这是代码的 Bug 导致的,后来经过排查发现,是因为线程池的 coreThreadCount 和 maxThreadCount 设置的比较小,导致任务在线程池里面大量的堆积,在调大了这两个参数之后问题就解决了。跳出这个坑之后,我就把重要线程池的队列任务堆积量 ,作为一个重要的监控指标放到了系统监控大屏上。

最后, 如果你使用线程池请一定记住 不要使用无界队列(即没有设置固定大小的队列) 。也许你会觉得使用了无界队列后,任务就永远不会被丢弃,只要任务对实时性要求不高,反正早晚有消费完的一天。但是,大量的任务堆积会占用大量的内存空间,一旦内存空间被占满就会频繁地触发 Full GC,造成服务不可用,我之前排查过的一次 GC 引起的宕机,起因就是系统中的一个线程池使用了无界队列。

理解了线程池的关键要点,你在系统里加上了这个特性,至此,系统稳定,你圆满完成了公司给你的研发任务。

这时,你回顾一下这两种技术,会发现它们都有一个 共同点: 它们所管理的对象,无论是连接还是线程,它们的创建过程都比较耗时,也比较消耗系统资源 。所以,我们把它们放在一个池子里统一管理起来,以达到 提升性能和资源复用的目的

这是一种常见的软件设计思想,叫做池化技术, 它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本,总之是好处多多。

不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间。

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

课程小结

本节课,我模拟了研发垂直电商系统最原始的场景,在遇到数据库查询性能下降的问题时,我们使用数据库连接池解决了频繁创建连接带来的性能问题,后面又使用线程池提升了并行查询数据库的性能。

其实,连接池和线程池你并不陌生,不过你可能对它们的原理和使用方式上还存在困惑或者误区,我在面试时,就发现有很多的同学对线程池的基本使用方式都不了解。借用这节课,我想再次强调的重点是:

  • 池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。
  • 池子中的对象需要在使用之前预先初始化完成,这叫做 池子的预热 ,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。
  • 池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。

7 - CH08-数据库-主从分离

上节课,我们用池化技术解决了数据库连接复用的问题,这时,你的垂直电商系统虽然整体架构上没有变化,但是和数据库交互的过程有了变化,在你的 Web 工程和数据库之间增加了数据库连接池,减少了频繁创建连接的成本,从上节课的测试来看性能上可以提升 80%。现在的架构图如下所示:

NAME

此时,你的数据库还是单机部署,依据一些云厂商的 Benchmark 的结果,在 4 核 8G 的机器上运 MySQL 5.7 时,大概可以支撑 500 的 TPS 和 10000 的 QPS。这时,运营负责人说正在准备双十一活动,并且公司层面会继续投入资金在全渠道进行推广,这无疑会引发查询量骤然增加的问题。那么今天,我们就一起来看看当查询请求增加时,应该如何做主从分离来解决问题。

主从读写分离

其实,大部分系统的访问模型是 读多写少,读写请求量的差距可能达到几个数量级。

这很好理解,刷朋友圈的请求量肯定比发朋友圈的量大,淘宝上一个商品的浏览量也肯定远大于它的下单量。因此,我们优先考虑数据库如何抗住更高的查询请求,那么首先你需要把读写流量区分开,因为这样才方便针对读流量做单独的扩展,这就是我们所说的主从读写分离。

它其实是个流量分离的问题,就好比道路交通管制一样,一个四车道的大马路划出三个车道给领导外宾通过,另外一个车道给我们使用,优先保证领导先行,就是这个道理。

这个方法本身是一种常规的做法,即使在一个大的项目中,它也是一个应对数据库突发读流量的有效方法。

我目前的项目中就曾出现过前端流量突增导致从库负载过高的问题,DBA 兄弟会优先做一个从库扩容上去,这样对数据库的读流量就会落入到多个从库上,从库的负载就降了下来,然后研发同学再考虑使用什么样的方案将流量挡在数据库层之上。

主从读写的两个技术关键点

一般来说在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或者多份,并且写入到其它的数据库服务器中,原始的数据库我们称为 主库,主要负责数据的写入,拷贝的目标数据库称为 从库,主要负责支持数据查询。可以看到,主从读写分离有两个技术上的关键点:

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

接下来,我们分别来看一看。

1. 主从复制

我先以 MySQL 为例介绍一下主从复制。

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

主从复制的过程是这样的

  1. 首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中
  2. 而主库也会创建一个 log dump 线程来发送 binlog 给从库;
  3. 同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。

在这个方案中,使用独立的 log dump 线程是一种异步的方式,可以避免对主库的主体更新流程产生影响,而从库在接收到信息后并不是写入从库的存储中,是写入一个 relay log,是避免写入从库实际存储会比较耗时,最终造成从库和主库延迟变长。

NAME

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

做了主从复制之后,我们就可以在写入时只写主库,在读数据时只读从库,这样即使写请求会锁表或者锁记录,也不会影响到读请求的执行。同时呢,在读流量比较大的情况下,我们可以部署多个从库共同承担读流量,这就是所说的 一主多从 部署方式,在你的垂直电商项目中就可以通过这种方式来抵御较高的并发读流量。另外,从库也可以当成一个备库来使用,以避免主库故障导致数据丢失。

那么你可能会说,是不是我无限制地增加从库的数量就可以抵抗大量的并发呢? 实际上并不是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3~5 个从库

当然,主从复制也有一些缺陷, 除了带来了部署上的复杂度,还有就是会带来一定的主从同步的延迟,这种延迟有时候会对业务产生一定的影响,我举个例子你就明白了。

在发微博的过程中会有些同步的操作,像是更新数据库的操作,也有一些异步的操作,比如说将微博的信息同步给审核系统,所以我们在更新完主库之后,会将微博的 ID 写入消息队列,再由队列处理机依据 ID 在从库中获取微博信息再发送给审核系统。 此时如果主从数据库存在延迟,会导致在从库中获取不到微博信息,整个流程会出现异常。

NAME

这个问题解决的思路有很多,核心思想就是尽量不去从库中查询信息 ,纯粹以上面的例子来说,我就有三种解决方案:

  1. 第一种方案是数据的冗余。

    你可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。

  2. 第二种方案是使用缓存。

    我可以在同步写数据库的同时,也把微博的数据写入到 Memcached 缓存里面,这样队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。

  3. 最后一种方案是查询主库。

    我可以在队列处理机中不查询从库而改为查询主库。不过,这种方式使用起来要慎重,要明确查询的量级不会很大,是在主库的可承受范围之内,否则会对主库造成比较大的压力。

我会优先考虑第一种方案,因为这种方式足够简单, 不过可能造成单条消息比较大,从而增加了消息发送的带宽和时间

缓存的方案比较 适合新增数据的场景,在更新数据的场景下, 先更新缓存可能会造成数据的不一致 ,比方说两个线程同时更新数据:

  1. 线程 A 把缓存中的数据更新为 1
  2. 此时另一个线程 B 把缓存中的数据更新为 2,然后线程 B 又更新数据库中的数据为 2,
  3. 此时线程 A 更新数据库中的数据为 1,这样数据库中的值(1)和缓存中的值(2)就不一致了。

最后,若非万不得已的情况下,我不会使用第三种方案。原因是这种方案要提供一个查询主库的接口,在团队开发的过程中,你很难保证其他同学不会滥用这个方法,而一旦主库承担了大量的读请求导致崩溃,那么对于整体系统的影响是极大的。

所以对这三种方案来说,你要有所取舍,根据实际项目情况做好选择。

另外,主从同步的延迟,是我们排查问题时很容易忽略的一个问题。 有时候我们遇到从数据库中获取不到信息的诡异问题时,会纠结于代码中是否有一些逻辑会把之前写入的内容删除,但是你又会发现,过了一段时间再去查询时又可以读到数据了,这基本上就是主从延迟在作怪。所以,一般我们会把从库落后的时间作为一个重点的数据库指标做监控和报警,正常的时间是在毫秒级别,一旦落后的时间达到了秒级别就需要告警了。

2. 如何访问数据库

我们已经使用主从复制的技术将数据复制到了多个节点,也实现了数据库读写的分离,这时,对于数据库的使用方式发生了变化。以前只需要使用一个数据库地址就好了,现在需要使用一个主库地址和多个从库地址,并且需要区分写入操作和查询操作,如果结合下一节课中要讲解的内容 分库分表,复杂度会提升更多。 为了降低实现的复杂度,业界涌现了很多数据库中间件来解决数据库的访问问题,这些中间件可以分为两类。

  1. 第一类以淘宝的 TDDL( Taobao Distributed Data Layer)为代表,以代码形式内嵌运行在应用程序内部

    你可以把它看成是一种数据源的代理,它的配置管理着多个数据源,每个数据源对应一个数据库,可能是主库,可能是从库。当有一个数据库请求时,中间件将 SQL 语句发给某一个指定的数据源来处理,然后将处理结果返回。

    这一类中间件的优点是简单易用,没有多余的部署成本,因为它是植入到应用程序内部,与应用程序一同运行的,所以比较适合运维能力较弱的小团队使用;缺点是缺乏多语言的支持,目前业界这一类的主流方案除了 TDDL,还有早期的网易 DDB,它们都是 Java 语言开发的,无法支持其他的语言。另外,版本升级也依赖使用方更新,比较困难。

  2. 另一类是单独部署的代理层方案

    这一类方案代表比较多,如早期阿里巴巴开源的 Cobar,基于 Cobar 开发出来的 Mycat,360 开源的 Atlas,美团开源的基于 Atlas 开发的 DBProxy 等等。

    这一类中间件部署在独立的服务器上,业务代码如同在使用单一数据库一样使用它,实际上它内部管理着很多的数据源,当有数据库请求时,它会对 SQL 语句做必要的改写,然后发往指定的数据源。

    它一般使用标准的 MySQL 通信协议,所以可以很好地支持多语言。由于它是独立部署的,所以也比较方便进行维护升级,比较适合有一定运维能力的大中型团队使用。它的缺陷是所有的 SQL 语句都需要跨两次网络:从应用到代理层和从代理层到数据源,所以在性能上会有一些损耗。

NAME

这些中间件,对你而言,可能并不陌生,但是我想让你注意到是, 在使用任何中间件的时候一定要保证对于中间件有足够深入的了解,否则一旦出了问题没法快速地解决就悲剧了。

我之前的一个项目中,一直使用自研的一个组件来实现分库分表,后来发现这套组件有一定几率会产生对数据库多余的连接,于是团队讨论后决定替换成 Sharding-JDBC。原本以为是一次简单的组件切换,结果上线后发现两个问题:

  • 一是因为使用姿势不对,会偶发地出现分库分表不生效导致扫描所有库表的情况,
  • 二是偶发地出现查询延时达到秒级别。

由于缺少对于 Sharding-JDBC 足够的了解,这两个问题我们都没有很快解决,后来不得已只能切回原来的组件,在找到问题之后再进行切换。

课程小结

本节课,我带你了解了查询量增加时,我们如何通过主从分离和一主多从部署抵抗增加的数据库流量的,你除了掌握主从复制的技术之外,还需要 了解主从分离会带来什么问题以及它们的解决办法 。这里我想让你明确的要点主要有:

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

其实,我们可以把主从复制引申为存储节点之间互相复制存储数据的技术 ,它可以实现数据的冗余,以达到备份和提升横向扩展能力的作用。在使用主从复制这个技术点时,你一般会考虑两个问题:

  1. 主从的一致性和写入性能的权衡

    如果你要保证所有从节点都写入成功,那么写入性能一定会受影响;

    如果你只写入主节点就返回成功,那么从节点就有可能出现数据同步失败的情况,从而造成主从不一致, 而在互联网的项目中,我们一般会优先考虑性能而不是数据的强一致性。

  2. 主从的延迟问题

    很多诡异的读取不到数据的问题都可能会和它有关,如果你遇到这类问题不妨先看看主从延迟的数据。

我们采用的很多组件都会使用到这个技术,比如,

  • Redis 也是通过主从复制实现读写分离;
  • Elasticsearch 中存储的索引分片也可以被复制到多个节点中;
  • 写入到 HDFS 中文件也会被复制到多个 DataNode 中。

只是不同的组件对于复制的一致性、延迟要求不同,采用的方案也不同。 但是这种设计的思想是通用的,是你需要了解的,这样你在学习其他存储组件的时候就能够触类旁通了。

8 - CH09-数据库-分库分表

前一节课,我们学习了在高并发下数据库的一种优化方案:读写分离,它就是依靠主从复制的技术使得数据库实现了数据复制为多份,增强了抵抗 大量并发读请求的能力,提升了数据库的查询性能的同时,也提升了数据的安全性,当某一个数据库节点,无论是主库还是从库发生故障时,我们还有其他的节点中存储着全量的数据,保证数据不会丢失。此时,你的电商系统的架构图变成了下面这样:

NAME

这时,公司 CEO 突然传来一个好消息,运营推广持续带来了流量,你所设计的电商系统的订单量突破了五千万,订单数据都是单表存储的,你的压力倍增,因为无论是数据库的查询还是写入性能都在下降,数据库的磁盘空间也在报警。所以,你主动分析现阶段自己需要考虑的问题,并寻求高效的解决方式,以便系统能正常运转下去。你考虑的问题主要有以下几点:

  1. 系统正在持续不断地的发展

    注册的用户越来越多,产生的订单越来越多,数据库中存储的数据也越来越多,单个表的数据量超过了千万甚至到了亿级别。这时即使你使用了索引,索引占用的空间也随着数据量的增长而增大,数据库就无法缓存全量的索引信息,那么就需要从磁盘上读取索引数据,就会影响到查询的性能了。 那么这时你要如何提升查询性能呢?

  2. 数据量的增加也占据了磁盘的空间,数据库在备份和恢复的时间变长, 你如何让数据库系统支持如此大的数据量呢?

  3. 不同模块的数据,比如用户数据和用户关系数据,全都存储在一个主库中,一旦主库发生故障,所有的模块儿都会受到影响, 那么如何做到不同模块的故障隔离呢?

  4. 你已经知道了,在 4 核 8G 的云服务器上对 MySQL5.7 做 Benchmark,大概可以支撑 500TPS 和 10000QPS,你可以看到数据库对于写入性能要弱于数据查询的能力,那么随着系统写入请求量的增长, 数据库系统如何来处理更高的并发写入请求呢?

这些问题你可以归纳成,数据库的写入请求量大造成的性能和可用性方面的问题 ,要解决这些问题,你所采取的措施就是 对数据进行分片 ,对数据进行分片,可以很好地分摊数据库的读写压力,也可以突破单机的存储瓶颈,而常见的一种方式是对数据库做 分库分表

分库分表是一个很常见的技术方案,你应该有所了解。那你会说了:「既然这个技术很普遍,而我又有所了解,那你为什么还要提及这个话题呢?」因为以我过往的经验来看,不少人会在分库分表这里踩坑,主要体现在:

  1. 对如何使用正确的分库分表方式一知半解,没有明白使用场景和方法。比如,一些同学会在查询时不使用分区键;
  2. 分库分表引入了一些问题后,没有找到合适的解决方案。比如,会在查询时使用大量连表查询等等。

本节课,我就带你解决这两个问题,从常人容易踩坑的地方,跳出来。

如何对数据库做垂直拆分

分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均的分配到多个数据库节点或者多个表中。

不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效的提升数据查询的性能 。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。

比如,我之前做过一个直播项目,在这个项目中,需要存储用户在直播间中发的消息以及直播间中的系统消息,你知道这些消息量极大,有些比较火的直播间有上万条留言是很常见的事儿,日积月累下来就积攒了几亿的数据,查询的性能和存储空间都扛不住了。没办法,就只能加班加点重构,启动多个数据库来分摊写入压力和容量的压力,也需要将原来单库的数据迁移到新启动的数据库节点上,好在最后成功完成分库分表和数据迁移校验工作,不过也着实花费了不少的时间和精力。

数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分。这两种方式,在我看来,掌握拆分方式是关键,理解拆分原理是内核。所以你在学习时,最好可以结合自身业务来思考。

垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的 表拆分到多个不同的数据库中

垂直拆分的原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。举个形象的例子就是在整理衣服的时候,将羽绒服、毛衣、T 恤分别放在不同的格子里。这样可以解决我在开篇提到的第三个问题:把不同的业务的数据分拆到不同的数据库节点上,这样一旦数据库发生故障时只会影响到某一个模块的功能,不会影响到整体功能,从而实现了数据层面的故障隔离。

我还是以微博系统为例来给你说明一下。

在微博系统中有和用户相关的表,有和内容相关的表,有和关系相关的表,这些表都存储在主库中。在拆分后,我们期望用户相关的表分拆到用户库中,内容相关的表分拆到内容库中,关系相关的表分拆到关系库中。

NAME

对数据库进行垂直拆分是一种偏常规的方式,这种方式其实你会比较常用,不过拆分之后,虽然可以暂时缓解存储容量的瓶颈,但并不是万事大吉,因为数据库垂直拆分后依然不能解决某一个业务模块的数据大量膨胀的问题,一旦你的系统遭遇某一个业务库的数据量暴增,在这个情况下,你还需要继续寻找可以弥补的方式。

比如微博关系量早已经过了千亿,单一的数据库或者数据表已经远远不能满足存储和查询的需求了, 这个时候,你需要将数据拆分到多个数据库和数据表中,也就是对数据库和数据表做水平拆分了

如何对数据库做水平拆分

和垂直拆分的关注点不同:

  • 垂直拆分的关注点在于 业务相关性
  • 水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在 数据的特点

拆分的规则有下面这两种:

  1. 按照某一个字段的 哈希值 做拆分

    这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库,64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值;对 64 取余,就得到了分表后的索引值。

NAME
  1. 另一种比较常用的是按照某一个字段的 区间 来拆分,比较常用的是时间字段。

    你知道在内容表里面有「创建时间」的字段,而我们也是按照时间来查看一个人发布的内容。我们可能会要看昨天的内容,也可能会看一个月前发布的内容,这时就可以按照创建时间的区间来分库分表,比如说可以把一个月的数据放入一张表中,这样在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。

    一般来说,列表数据可以使用这种拆分方式,比如一个人一段时间的订单,一段时间发布的内容。但是这种方式可能会存在明显的热点,这很好理解嘛,你当然会更关注最近我买了什么,发了什么,所以查询的 QPS 也会更多一些,对性能有一定的影响。另外,使用这种拆分规则后,数据表要提前建立好,否则如果时间到了 2020 年元旦,DBA(Database Administrator,数据库管理员)却忘记了建表,那么 2020 年的数据就没有库表可写了,就会发生故障了。

NAME

数据库在分库分表之后,数据的访问方式也有了极大的改变,原先只需要根据查询条件到从库中查询数据即可,现在则需要先确认数据在哪一个库表中,再到那个库表中查询数据 。这种复杂度也可以通过数据库中间件来解决,我们在上一节中已经有所讲解,这里就不再赘述了,不过,我想再次强调的是你需要对所使用数据库中间件的原理有足够的了解和足够强的运维上的把控能力。

不过,你要知道的是,分库分表虽然能够解决数据库扩展性的问题,但是它也给我们的使用带来了一些问题。

解决分库分表引入的问题

分库分表引入的一个最大的问题就是 引入了分库分表键,也叫做分区键, 也就是我们对数据库做分库分表所依据的字段。

从分库分表规则中你可以看到,无论是哈希拆分还是区间段的拆分,我们首先都需要选取一个数据库字段,这带来一个问题是:我们之后所有的查询都需要带上这个字段,才能找到数据所在的库和表,否则就只能向所有的数据库和数据表发送查询命令。如果像上面说的要拆分成 16 个库和 64 张表,那么一次数据的查询会变成 16*64=1024 次查询,查询的性能肯定是极差的。

当然,方法总比问题多, 针对这个问题,我们也会有一些相应的解决思路。比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户时,你可以按照昵称作为分区键再做一次拆分,但是这样会极大的增加存储成本,如果以后我们还需要按照注册时间来查询时要怎么办呢,再做一次拆分吗?

所以最合适的思路是 你要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据,这个表也可以是分库分表的,也需要占用一定的存储空间,但是因为表中只有两个字段,所以相比重新做一次拆分还是会节省不少的空间的。

分库分表引入的另外一个问题是一些数据库的特性在实现时可能变得很困难。 比如说多表的 join 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了,不过好在我们对于 join 的需求不高,即使有也一般是把两个表的数据取出后在业务代码里面做筛选,复杂是有一些,不过是可以实现的。再比如说在未分库分表之前查询数据总数时只需要在 SQL 中执行 count() 即可,现在数据被分散到多个库表中,我们可能要考虑其他的方案,比方说将计数的数据单独存储在一张表中或者记录在 Redis 里面。

当然,虽然分库分表会对我们使用数据库带来一些不便,但是相比它所带来的扩展性和性能方面的提升,我们还是需要做的,因为,经历过分库分表后的系统,才能够突破单机的容量和请求量的瓶颈 ,就比如说,我在开篇提到的我们的电商系统,它正是经历了分库分表,才会解决订单表数据量过大带来的性能衰减和容量瓶颈。

课程小结

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

而且,你需要了解的是在实现分库分表过程中,数据从单库单表迁移多库多表是一件即繁杂又容易出错的事情,而且如果我们初期没有规划得当,后面要继续增加数据库数或者表数时,我们还要经历这个迁移的过程。所以,从我的经验出发,对于分库分表的原则主要有以下几点:

  1. 如果在性能上没有瓶颈点那么就尽量不做分库分表;
  2. 如果要做,就尽量一次到位,比如说 16 库 64 表就基本能够满足为了几年内你的业务的需求。
  3. 很多的 NoSQL 数据库,例如 Hbase,MongoDB 都提供 auto sharding 的特性,如果你的团队内部对于这些组件比较熟悉,有较强的运维能力,那么也可以考虑使用这些 NoSQL 数据库替代传统的关系型数据库。

其实,在我看来,有很多人并没有真正从根本上搞懂为什么要拆分,拆分后会带来哪些问题,只是一味地学习大厂现有的拆分方法,从而导致问题频出。 所以,你在使用一个方案解决一个问题的时候一定要弄清楚原理,搞清楚这个方案会带来什么问题,要如何来解决,要知其然也知其所以然,这样才能在解决问题的同时避免踩坑。

9 - CH10-数据库-发号器

在前面两节课程中,我带你了解了分布式存储两个核心问题:数据冗余和数据分片,以及在传统关系型数据库中是如何解决的。

当我们面临高并发的查询数据请求时,可以使用主从读写分离的方式,部署多个从库分摊读压力;

当存储的数据量达到瓶颈时,我们可以将数据分片存储在多个节点上,降低单个存储节点的存储压力,此时我们的架构变成了下面这个样子:

NAME

你可以看到,我们通过分库分表和主从读写分离的方式解决了数据库的扩展性问题,但是在 09 讲我也提到过,数据库在分库分表之后,我们在使用数据库时存在的许多限制,比方说查询的时候必须带着分区键;一些聚合类的查询(像是 count())性能较差,需要考虑使用计数器等其它的解决方案,其实分库分表还有一个问题我在 09 中没有提到,就是 主键的全局唯一性的问题 。本节课,我将带你一起来了解,在分库分表后如何生成全局唯一的数据库主键。

不过,在探究这个问题之前,你需要对「使用什么字段作为主键」这个问题有所了解,这样才能为我们后续探究如何生成全局唯一的主键做好铺垫。

数据库的主键要如何选择?

数据库中的每一条记录都需要有一个唯一的标识,依据数据库的第二范式,数据库中每一个表中都需要有一个唯一的主键,其他数据元素和主键一一对应。

那么关于主键的选择就成为一个关键点了, 一般来讲,你有两种选择方式:

  1. 使用业务字段作为主键,比如说对于用户表来说,可以使用手机号,email 或者身份证号作为主键。
  2. 使用生成的唯一 ID 作为主键。

不过对于大部分场景来说,第一种选择并不适用,比如像评论表你就很难找到一个业务字段作为主键,因为在评论表中,你很难找到一个字段唯一标识一条评论。而对于用户表来说,我们需要考虑的是作为主键的业务字段是否能够唯一标识一个人,一个人可以有多个 email 和手机号,一旦出现变更 email 或者手机号的情况,就需要变更所有引用的外键信息,所以使用 email 或者手机作为主键是不合适的。

身份证号码确实是用户的唯一标识,但是由于它的隐私属性,并不是一个用户系统的必须属性,你想想,你的系统如果没有要求做实名认证,那么肯定不会要求用户填写身份证号码的。并且已有的身份证号码是会变更的,比如在 1999 年时身份证号码就从 15 位变更为 18 位,但是主键一旦变更,以这个主键为外键的表也都要随之变更,这个工作量是巨大的。

因此,我更倾向于使用生成的 ID 作为数据库的主键。 不单单是因为它的唯一性,更是因为一旦生成就不会变更,可以随意引用。

在单库单表的场景下,我们可以使用数据库的自增字段作为 ID,因为这样最简单,对于开发人员来说也是透明的。但是当数据库分库分表后,使用自增字段就无法保证 ID 的全局唯一性了。

想象一下,当我们分库分表之后,同一个逻辑表的数据被分布到多个库中,这时如果使用数据库自增字段作为主键,那么只能保证在这个库中是唯一的,无法保证全局的唯一性。那么假如你来设计用户系统的时候,使用自增 ID 作为用户 ID,就可能出现两个用户有两个相同的 ID,这是不可接受的,那么你要怎么做呢?我建议你搭建发号器服务来生成全局唯一的 ID。

基于 Snowflake 算法搭建发号器

从我历年所经历的项目中,我主要使用的是 变种的 Snowflake 算法来生成业务需要的 ID 的 ,本讲的重点,也是运用它去解决 ID 全局唯一性的问题。搞懂这个算法,知道它是怎么实现的,就足够你应用它来设计一套分布式发号器了,不过你可能会说了:「那你提全局唯一性,怎么不提 UUID 呢?」

没错,UUID(Universally Unique Identifier,通用唯一标识码)不依赖于任何第三方系统,所以在性能和可用性上都比较好,我一般会使用它生成 Request ID 来标记单次请求, 但是如果用它来作为数据库主键,它会存在以下几点问题。

首先,生成的 ID 做好具有单调递增性,也就是有序的,而 UUID 不具备这个特点。为什么 ID 要是有序的呢? 因为在系统设计时,ID 有可能成为排序的字段。 我给你举个例子。

比如,你要实现一套评论的系统时,你一般会设计两个表,一张评论表,存储评论的详细信息,其中有 ID 字段,有评论的内容,还有评论人 ID,被评论内容的 ID 等等,以 ID 字段作为分区键;另一个是评论列表,存储着内容 ID 和评论 ID 的对应关系,以内容 ID 为分区键。

我们在获取内容的评论列表时,需要按照时间序倒序排列,因为 ID 是时间上有序的,所以我们就可以按照评论 ID 的倒序排列。而如果评论 ID 不是在时间上有序的话,我们就需要在评论列表中再存储一个多余的创建时间的列用作排序,假设内容 ID、评论 ID 和时间都是使用 8 字节存储,我们就要多出 50% 的存储空间存储时间字段,造成了存储空间上的浪费。

另一个原因在于 ID 有序也会提升数据的写入性能。

我们知道 MySQL InnoDB 存储引擎使用 B+ 树 存储索引数据,而主键也是一种索引。索引数据在 B+ 树 中是有序排列的,就像下面这张图一样,图中 2,10,26 都是记录的 ID,也是索引数据。

NAME

这时,当插入的下一条记录的 ID 是递增的时候,比如插入 30 时,数据库只需要把它追加到后面就好了。但是如果插入的数据是无序的,比如 ID 是 13,那么数据库就要查找 13 应该插入的位置,再挪动 13 后面的数据,这就造成了多余的数据移动的开销。

NAME

我们知道机械磁盘在完成随机的写时,需要先做 「寻道」找到要写入的位置,也就是让磁头找到对应的磁道,这个过程是非常耗时的。而顺序写就不需要寻道,会大大提升索引的写入性能

UUID 不能作为 ID 的另一个原因是它不具备业务含义, 其实现实世界中使用的 ID 中都包含有一些有意义的数据,这些数据会出现在 ID 的固定的位置上。比如说我们使用的身份证的前六位是地区编号;7~14 位是身份证持有人的生日;不同城市电话号码的区号是不同的;你从手机号码的的前三位就可以看出这个手机号隶属于哪一个运营商。而如果生成的 ID 可以被反解,那么从反解出来的信息中我们可以对 ID 来做验证,我们可以从中知道这个 ID 的生成时间,从哪个机房的发号器中生成的,为哪个业务服务的,对于问题的排查有一定的帮助。

最后,UUID 是由 32 个 16 进制数字组成的字符串,如果作为数据库主键使用比较耗费空间。

你能看到,UUID 方案有很大的局限性,也是我不建议你用它的原因,而 twitter 提出的 Snowflake 算法完全可以弥补 UUID 存在的不足,因为它不仅算法简单易实现,也满足 ID 所需要的全局唯一性,单调递增性,还包含一定的业务上的意义。

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

NAME

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

如果你的系统部署在多个机房,那么 10 位的机器 ID 可以继续划分为 2~3 位的 IDC 标示(可以支撑 4 个或者 8 个 IDC 机房)和 7~8 位的机器 ID(支持 128-256 台机器);12 位的序列号代表着每个节点每毫秒最多可以生成 4096 的 ID。

不同公司也会依据自身业务的特点对 Snowflake 算法做一些改造,比如说减少序列号的位数增加机器 ID 的位数以支持单 IDC 更多的机器,也可以在其中加入业务 ID 字段来区分不同的业务。 比方说我现在使用的发号器的组成规则就是: 1 位兼容位恒为 0 + 41 位时间信息 + 6 位 IDC 信息(支持 64 个 IDC)+ 6 位业务信息(支持 64 个业务)+ 10 位自增信息(每毫秒支持 1024 个号)

我选择这个组成规则,主要是因为我在单机房只部署一个发号器的节点,并且使用 KeepAlive 保证可用性。业务信息指的是项目中哪个业务模块使用,比如用户模块生成的 ID,内容模块生成的 ID,把它加入进来,一是希望不同业务发出来的 ID 可以不同,二是因为在出现问题时可以反解 ID,知道是哪一个业务发出来的 ID。

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

一种是嵌入到业务代码里,也就是分布在业务服务器中。 这种方案的好处是业务代码在使用的时候不需要跨网络调用,性能上会好一些,但是就需要更多的机器 ID 位数来支持更多的业务服务器。另外,由于业务服务器的数量很多,我们很难保证机器 ID 的唯一性,所以就需要引入 ZooKeeper 等分布式一致性组件来保证每次机器重启时都能获得唯一的机器 ID。

另外一个部署方式是作为独立的服务部署,这也就是我们常说的发号器服务。 业务在使用发号器的时候就需要多一次的网络调用,但是内网的调用对于性能的损耗有限,却可以减少机器 ID 的位数,如果发号器以主备方式部署,同时运行的只有一个发号器,那么机器 ID 可以省略,这样可以留更多的位数给最后的自增信息位。即使需要机器 ID,因为发号器部署实例数有限,那么就可以把机器 ID 写在发号器的配置文件里,这样即可以保证机器 ID 唯一性,也无需引入第三方组件了。 微博和美图都是使用独立服务的方式来部署发号器的,性能上单实例单 CPU 可以达到两万每秒。

Snowflake 算法设计的非常简单且巧妙,性能上也足够高效,同时也能够生成 具有全局唯一性、单调递增性和有业务含义的 ID ,但是它也有一些缺点,其中最大的缺点就是它依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止。

另外,如果请求发号器的 QPS 不高,比如说发号器每毫秒只发一个 ID,就会造成生成 ID 的末位永远是 1,那么在分库分表时如果使用 ID 作为分区键就会造成库表分配的不均匀。 这一点,也是我在实际项目中踩过的坑,而解决办法主要有两个:

  1. 时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多发出几个号,避免出现分库分表时数据分配不均。
  2. 生成的序列号的起始号可以做一下随机,这一秒是 21,下一秒是 30,这样就会尽量的均衡了。

我在开头提到,自己的实际项目中采用的是变种的 Snowflake 算法,也就是说对 Snowflake 算法进行了一定的改造,从上面的内容中你可以看出,这些改造:

  • 一是要让算法中的 ID 生成规则符合自己业务的特点;
  • 二是为了解决诸如时间回拨等问题。

其实,大厂除了采取 Snowflake 算法之外,还会选用一些其他的方案,比如滴滴和美团都有提出基于数据库生成 ID 的方案。这些方法根植于公司的业务,同样能解决分布式环境下 ID 全局唯一性的问题。对你而言,可以多角度了解不同的方法,这样能够寻找到更适合自己业务目前场景的解决方案,不过我想说的是, 方案不在多,而在精,方案没有最好,只有最适合,真正弄懂方法背后的原理,并将它落地,才是你最佳的选择。

课程小结

本节课,我结合自己的项目经历带你了解了如何使用 Snowflake 算法解决分库分表后数据库 ID 的全局唯一的问题,在这个问题中,又延伸性地带你了解了生成的 ID 需要满足单调递增性,以及要具有一定业务含义的特性。当然,我们重点的内容是讲解如何将 Snowflake 算法落地,以及在落地过程中遇到了哪些坑,带你去解决它。

Snowflake 的算法并不复杂,你在使用的时候可以不考虑独立部署的问题,先想清楚按照自身的业务场景,需要如何设计 Snowflake 算法中的每一部分占的二进制位数。比如你的业务会部署几个 IDC,应用服务器要部署多少台机器,每秒钟发号个数的要求是多少等等,然后在业务代码中实现一个简单的版本先使用,等到应用服务器数量达到一定规模,再考虑独立部署的问题就可以了。这样可以避免多维护一套发号器服务,减少了运维上的复杂度。

10 - CH11-数据库-NoSQL

前几节课,我带你了解了在你的垂直电商项目中,如何将传统的关系型数据库改造成分布式存储服务,以抵抗高并发和大流量的冲击。

对于存储服务来说,我们一般会从两个方面对它做改造:

  1. 提升它的读写性能,尤其是读性能,因为我们面对的多是一些读多写少的产品。比方说,你离不开的微信朋友圈、微博和淘宝,都是查询 QPS 远远大于写入 QPS。
  2. 增强它在存储上的扩展能力,从而应对大数据量的存储需求。

我之前带你学习的读写分离和分库分表就是从这两方面出发,改造传统的关系型数据库的,但仍有一些问题无法解决。

比如,在微博项目中关系的数据量达到了千亿,那么即使分隔成 1024 个库表,每张表的数据量也达到了亿级别,并且关系的数据量还在以极快的速度增加,即使你分隔成再多的库表,数据量也会很快增加到瓶颈。这个问题用传统数据库很难根本解决,因为它在扩展性方面是很弱的,这时,就可以利用 NoSQL,因为它有着天生分布式的能力,能够提供优秀的读写性能,可以很好地补充传统关系型数据库的短板。那么它是如何做到的呢?

这节课,我就还是以你的垂直电商系统为例,带你掌握如何用 NoSQL 数据库和关系型数据库互补 ,共同承担高并发和大流量的冲击。

首先,我们先来了解一下 NoSQL 数据库。

NoSQL,No SQL?

NoSQL 想必你很熟悉,它指的是不同于传统的关系型数据库的其他数据库系统的统称,它不使用 SQL 作为查询语言,提供优秀的横向扩展能力和读写性能,非常契合互联网项目高并发大数据的特点。所以一些大厂,比如小米、微博、陌陌都很倾向使用它来作为高并发大容量的数据存储服务。

NoSQL 数据库发展到现在,十几年间,出现了多种类型,我来给你举几个例子:

  • Redis、LevelDB 这样的 KV 存储。这类存储相比于传统的数据库的优势是极高的读写性能,一般对性能有比较高的要求的场景会使用。
  • Hbase、Cassandra 这样的 列式存储数据库。这种数据库的特点是数据不像传统数据库以行为单位来存储,而是以列来存储,适用于一些离线数据统计的场景。
  • 像 MongoDB、CouchDB 这样的文档型数据库。这种数据库的特点是 Schema Free(模式自由),数据表中的字段可以任意扩展,比如说电商系统中的商品有非常多的字段,并且不同品类的商品的字段也都不尽相同,使用关系型数据库就需要不断增加字段支持,而用文档型数据库就简单很多了。

在 NoSQL 数据库刚刚被应用时,它被认为是可以替代关系型数据库的银弹,在我看来,也许因为以下几个方面的原因:

  • 弥补了传统数据库在性能方面的不足;
  • 数据库变更方便,不需要更改原先的数据结构;
  • 适合互联网项目常见的大数据量的场景;

不过,这种看法是个误区,因为慢慢地我们发现在业务开发的场景下还是需要利用 SQL 语句的强大的查询功能以及传统数据库事务和灵活的索引等功能,NoSQL 只能作为一些场景的补充。

那么接下来,我就带你了解 NoSQL 数据库是如何做到与关系数据库互补的。 了解这部分内容,你可以在实际项目中更好地使用 NoSQL 数据库补充传统数据库的不足。

首先,我们来关注一下数据库的写入性能。

使用 NoSQL 提升写入性能

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

  • 一种是随机 IO

    随机 IO 就需要花费时间做昂贵的磁盘寻道,一般来说,它的读写效率要比顺序 IO 小两到三个数量级,所以我们想要提升写入的性能就要尽量减少随机 IO

  • 另一种是顺序 IO

以 MySQL 的 InnoDB 存储引擎来说,更新 binlog、redolog、undolog 都是在做顺序 IO,而更新 datafile 和索引文件则是在做随机 IO,而为了减少随机 IO 的发生,关系数据库已经做了很多的优化,比如说写入时先写入内存,然后批量刷新到磁盘上,但是随机 IO 还是会发生。

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

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

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

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

它的思想很简单,数据首先会写入到一个叫做 MemTable 的内存结构中,在 MemTable 中数据是按照写入的 Key 来排序的。为了防止 MemTable 里面的数据因为机器掉电或者重启而丢失,一般会通过写 Write Ahead Log 的方式将数据备份在磁盘上。

MemTable 在累积到一定规模时,它会被刷新生成一个新的文件,我们把这个文件叫做 SSTable(Sorted String Table)。当 SSTable 达到一定数量时,我们会将这些 SSTable 合并,减少文件的数量,因为 SSTable 都是有序的,所以合并的速度也很快。

当从 LSM 树里面读数据时,我们首先从 MemTable 中查找数据,如果数据没有找到,再从 SSTable 中查找数据。因为存储的数据都是有序的,所以查找的效率是很高的,只是因为数据被拆分成多个 SSTable,所以读取的效率会低于 B+ 树索引。

NAME

和 LSM 树类似的算法有很多,比如说 TokuDB 使用的名为 Fractal tree 的索引结构,它们的核心思想就是将随机 IO 变成顺序的 IO,从而提升写入的性能。

在后面的缓存篇中,我也将给你着重介绍我们是如何使用 KV 型 NoSQL 存储来提升读性能的。所以你看,NoSQL 数据库补充关系型数据库的第一种方式就是提升读写性能。

场景补充

除了可以提升性能之外,NoSQL 数据库还可以在某些场景下作为传统关系型数据库的补充,来看一个具体的例子。

假设某一天,CEO 找到你并且告诉你,他正在为你的垂直电商项目规划搜索的功能,需要支持按照商品的名称模糊搜索到对应的商品,希望你尽快调研出解决方案。

一开始,你认为这非常的简单,不就是在数据库里面执行一条类似:select * from product where name like ‘%***%’ 的语句吗?可是在实际执行的过程中,却发现了问题。

你发现这类语句并不是都能使用到索引,只有后模糊匹配的语句才能使用索引。比如语句 select * from product where name like ‘% 电冰箱’ 就没有使用到字段 name 上的索引,而 select * from product where name like ‘索尼 %’ 就使用了 name 上的索引。而一旦没有使用索引就会扫描全表的数据,在性能上是无法接受的。

于是你在谷歌上搜索了一下解决方案,发现大家都在使用开源组件 Elasticsearch 来支持搜索的请求,它本身是基于“倒排索引”来实现的, 那么什么是倒排索引呢?

倒排索引是指将记录中的某些列做分词,然后形成的分词与记录 ID 之间的映射关系。比如说,你的垂直电商项目里面有以下记录:

NAME

那么,我们将商品名称做简单的分词,然后建立起分词和商品 ID 的对应关系,就像下面展示的这样:

NAME

这样,如果用户搜索电冰箱,就可以给他展示商品 ID 为 1 和 3 的两件商品了。

而 Elasticsearch 作为一种常见的 NoSQL 数据库, 就以倒排索引作为核心技术原理,为你提供了分布式的全文搜索服务,这在传统的关系型数据库中使用 SQL 语句是很难实现的。 所以你看,NoSQL 可以在某些业务场景下代替传统数据库提供数据存储服务。

提升扩展性

另外,在扩展性方面,很多 NoSQL 数据库也有着先天的优势。还是以你的垂直电商系统为例,你已经为你的电商系统增加了评论系统,开始你的评估比较乐观,觉得电商系统的评论量级不会增长很快,所以就为它分了 8 个库,每个库拆分成 16 张表。

但是评论系统上线之后,存储量级增长的异常迅猛,你不得不将数据库拆分成更多的库表,而数据也要重新迁移到新的库表中,过程非常痛苦,而且数据迁移的过程也非常容易出错。

这时,你考虑是否可以考虑使用 NoSQL 数据库来彻底解决扩展性的问题,经过调研你发现它们在设计之初就考虑到了分布式和大数据存储的场景, 比如像 MongoDB 就有三个扩展性方面的特性。

  • 其一是 Replica,也叫做副本集,你可以理解为主从分离,也就是通过将数据拷贝成多份来保证当主挂掉后数据不会丢失。同时呢,Replica 还可以分担读请求。Replica 中有主节点来承担写请求,并且把对数据变动记录到 oplog 里(类似于 binlog);从节点接收到 oplog 后就会修改自身的数据以保持和主节点的一致。一旦主节点挂掉,MongoDB 会从从节点中选取一个节点成为主节点,可以继续提供写数据服务。
  • 其二是 Shard,也叫做分片,你可以理解为分库分表,即将数据按照某种规则拆分成多份,存储在不同的机器上。MongoDB 的 Sharding 特性一般需要三个角色来支持,一个是 Shard Server,它是实际存储数据的节点,是一个独立的 Mongod 进程;二是 Config Server,也是一组 Mongod 进程,主要存储一些元信息,比如说哪些分片存储了哪些数据等;最后是 Route Server,它不实际存储数据,仅仅作为路由使用,它从 Config Server 中获取元信息后,将请求路由到正确的 Shard Server 中。
NAME
  • 其三是负载均衡,就是当 MongoDB 发现 Shard 之间数据分布不均匀,会启动 Balancer 进程对数据做重新的分配,最终让不同 Shard Server 的数据可以尽量的均衡。当我们的 Shard Server 存储空间不足需要扩容时,数据会自动被移动到新的 Shard Server 上,减少了数据迁移和验证的成本。

你可以看到,NoSQL 数据库中内置的扩展性方面的特性可以让我们不再需要对数据库做分库分表和主从分离,也是对传统数据库一个良好的补充。

你可能会觉得,NoSQL 已经成熟到可以代替关系型数据库了,但是就目前来看,NoSQL 只能作为传统关系型数据库的补充而存在,弥补关系型数据库在性能、扩展性和某些场景下的不足,所以你在使用或者选择时要结合自身的场景灵活地运用。

课程小结

本节课我带你了解了 NoSQL 数据库在性能、扩展性上的优势,以及它的一些特殊功能特性,主要有以下几点:

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

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

我在之前的项目中曾经使用 Elasticsearch 作为持久存储,支撑社区的 feed 流功能,初期开发的时候确实很爽,你可以针对 feed 中的任何字段做灵活高效地查询,业务功能迭代迅速,代码也简单易懂。可是到了后期流量上来之后,由于缺少对于 Elasticsearch 成熟的运维能力,造成故障频出,尤其到了高峰期就会出现节点不可用的问题,而由于业务上的巨大压力又无法分出人力和精力对 Elasticsearch 深入的学习和了解,最后不得不做大的改造切回熟悉的 MySQL。 所以,对于开源组件的使用,不能只停留在只会 hello world 的阶段,而应该对它有足够的运维上的把控能力。

11 - CH12-缓存-基础

通过前面数据库篇的学习,你已经了解了在高并发大流量下,数据库层的演进过程以及库表设计上的考虑点。你的垂直电商系统在完成了对数据库的主从分离和分库分表之后,已经可以支撑十几万 DAU 了,整体系统的架构也变成了下面这样:

NAME

从整体上看,数据库分了主库和从库,数据也被切分到多个数据库节点上。但随着并发的增加,存储数据量的增多,数据库的磁盘 IO 逐渐成了系统的瓶颈,我们需要一种访问更快的组件来降低请求响应时间,提升整体系统性能。这时我们就会使用缓存。 那么什么是缓存,我们又该如何将它的优势最大化呢?

本节课是缓存篇的总纲, 我将从缓存定义、缓存分类和缓存优势劣势三个方面全方位带你掌握缓存的设计思想和理念,再用剩下 4 节课的时间,带你针对性地掌握使用缓存的正确姿势,以便让你在实际工作中能够更好地使用缓存提升整体系统的性能。

什么是缓存

缓存,是一种存储数据的组件,它的作用是让对数据的请求更快地返回。

我们经常会把缓存放在内存中来存储, 所以有人就把内存和缓存画上了等号,这完全是外行人的见解。作为业内人士,你要知道在某些场景下我们可能还会使用 SSD 作为冷数据的缓存。比如说 360 开源的 Pika 就是使用 SSD 存储数据解决 Redis 的容量瓶颈的。

实际上,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存 。那么说到这儿我们就需要知道常见硬件组件的延时情况是什么样的了,这样在做方案的时候可以对延迟有更直观的印象。幸运的是,业内已经有人帮我们总结出这些数据了,我将这些数据整理了一下,你可以看一下。

NAME

从这些数据中,你可以看到,做一次内存寻址大概需要 100ns,而做一次磁盘的查找则需要 10ms。如果我们将做一次内存寻址的时间类比为一个课间,那么做一次磁盘查找相当于度过了大学的一个学期。可见,我们使用内存作为缓存的存储介质相比于以磁盘作为主要存储介质的数据库来说,性能上会提高多个数量级,同时也能够支撑更高的并发量。所以,内存是最常见的一种缓存数据的介质。

缓存作为一种常见的 空间换时间的性能优化手段,在很多地方都有应用,我们先来看几个例子,相信你一定不会陌生。

1. 缓存案例

Linux 内存管理是通过一个叫做 MMU(Memory Management Unit)的硬件,来实现从虚拟地址到物理地址的转换的,但是如果每次转换都要做这么复杂计算的话,无疑会造成性能的损耗,所以我们会借助一个叫做 TLB(Translation Lookaside Buffer)的组件来缓存最近转换过的虚拟地址,和物理地址的映射。TLB 就是一种缓存组件,缓存复杂运算的结果,就好比你做一碗色香味俱全的面条可能比较复杂,那么我们把做好的面条油炸处理一下做成方便面,你做方便面的话就简单多了,也快速多了。这个缓存组件比较底层,这里你只需要了解一下就可以了。

在大部分的笔记本,桌面电脑和服务器上都会有一个或者多个 TLB 组件,在不经意间帮助我们加快地址转换的速度。

再想一下你平时经常刷的抖音。 平台上的短视频实际上是使用内置的网络播放器来完成的。网络播放器接收的是数据流,将数据下载下来之后经过分离音视频流,解码等流程后输出到外设设备上播放。

如果我们在打开一个视频的时候才开始下载数据的话,无疑会增加视频的打开速度(我们叫首播时间),并且播放过程中会有卡顿。所以我们的播放器中通常会设计一些缓存的组件,在未打开视频时缓存一部分视频数据,比如我们打开抖音,服务端可能一次会返回三个视频信息,我们在播放第一个视频的时候,播放器已经帮我们缓存了第二、三个视频的部分数据,这样在看第二个视频的时候就可以给用户 「秒开」 的感觉。

除此之外,我们熟知的 HTTP 协议也是有缓存机制的。当我们第一次请求静态的资源时,比如一张图片,服务端除了返回图片信息,在响应头里面还有一个 Etag 的字段。浏览器会缓存图片信息以及这个字段的值。当下一次再请求这个图片的时候,浏览器发起的请求头里面会有一个 If-None-Match 的字段,并且把缓存的 Etag 的值写进去发给服务端。服务端比对图片信息是否有变化,如果没有,则返回浏览器一个 304 的状态码,浏览器会继续使用缓存的图片信息。通过这种缓存协商的方式,可以减少网络传输的数据大小,从而提升页面展示的性能。

NAME

2. 缓存与缓冲区

讲了这么多缓存案例,想必你对缓存已经有了一个直观并且形象的了解了。除了缓存,我们在日常开发过程中还会经常听见一个相似的名词—— 缓冲区 ,那么,什么是缓冲区呢?缓冲和缓存只有一字之差,它们有什么区别呢?

我们知道,缓存可以提高低速设备的访问速度,或者减少复杂耗时的计算带来的性能问题。理论上说,我们可以通过缓存解决所有关于「慢」的问题,比如从磁盘随机读取数据慢,从数据库查询数据慢,只是不同的场景消耗的存储成本不同。

缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。 缓冲区更像「消息队列篇」中即将提到的消息队列,用以弥补高速设备和低速设备通信时的速度差 。比如,我们将数据写入磁盘时并不是直接刷盘,而是写到一块缓冲区里面,内核会标识这个缓冲区为脏。当经过一定时间或者脏缓冲区比例到达一定阈值时,由单独的线程把脏块刷新到硬盘。这样避免了每次写数据都要刷盘带来的性能问题。

NAME

以上就是缓冲区和缓存的区别,从这个区别来看,上面提到的 TLB 的命名是有问题的,它应该是缓存而不是缓冲区。

现在你已经了解了缓存的含义,那么我们经常使用的缓存都有哪些?我们又该如何使用缓存,将它的优势最大化呢?

缓存分类

在我们日常开发中,常见的缓存主要就是 静态缓存、分布式缓存和热点本地缓存 这三种。

静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。例如,我们在做一些内容管理系统的时候,后台会录入很多的文章,前台在网站上展示文章内容,就像新浪,网易这种门户网站一样。

当然,我们也可以把文章录入到数据库里面,然后前端展示的时候穿透查询数据库来获取数据,但是这样会对数据库造成很大的压力。即使我们使用分布式缓存来挡读请求,但是对于像日均 PV 几十亿的大型门户网站来说,基于成本考虑仍然是不划算的。

**所以我们的解决思路是 ** 每篇文章在录入的时候渲染成静态页面,放置在所有的前端 Nginx 或者 Squid 等 Web 服务器上,这样用户在访问的时候会优先访问 Web 服务器上的静态页面,在对旧的文章执行一定的清理策略后,依然可以保证 99% 以上的缓存命中率。

这种缓存只能针对静态数据来缓存,对于动态请求就无能为力了。那么我们如何针对动态请求做缓存呢? 这时你就需要分布式缓存了。

分布式缓存的大名可谓是如雷贯耳了,我们平时耳熟能详的 Memcached、Redis 就是分布式缓存的典型例子。它们性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色(接下来的课程我会专门针对分布式缓存,带你了解分布式缓存的使用技巧以及高可用的方案,让你能在工作中对分布式缓存运用自如)。

对于静态的资源的缓存你可以选择静态缓存,对于动态的请求你可以选择分布式缓存, 那么什么时候要考虑热点本地缓存呢?

答案是当我们遇到极端的热点数据查询的时候。 热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。

比如某一位明星在微博上有了热点话题,「吃瓜群众」会到他 (她) 的微博首页围观,这就会引发这个用户信息的热点查询。这些查询通常会命中某一个缓存节点或者某一个数据库分区,短时间内会形成极高的热点查询。

那么我们会在代码中使用一些本地缓存方案,如 HashMap,Guava Cache 或者是 Ehcache 等,它们和应用程序部署在同一个进程中,优势是不需要跨网络调度,速度极快,所以可以来阻挡短时间内的热点查询。 来看个例子。

**比方说 ** 你的垂直电商系统的首页有一些推荐的商品,这些商品信息是由编辑在后台录入和变更。你分析编辑录入新的商品或者变更某个商品的信息后,在页面的展示是允许有一些延迟的,比如说 30 秒的延迟,并且首页请求量最大,即使使用分布式缓存也很难抗住,所以你决定使用 Guava Cache 来将所有的推荐商品的信息缓存起来,并且设置每隔 30 秒重新从数据库中加载最新的所有商品。

首先,我们初始化 Guava 的 Loading Cache:

CacheBuilder<String, List<Product>> cacheBuilder = CacheBuilder.newBuilder().maximumSize(maxSize).recordStats(); // 设置缓存最大值
cacheBuilder = cacheBuilder.refreshAfterWrite(30, TimeUnit.Seconds); // 设置刷新间隔
 
LoadingCache<String, List<Product>> cache = cacheBuilder.build(new CacheLoader<String, List<Product>>() {
    @Override
    public List<Product> load(String k) throws Exception {
        return productService.loadAll(); // 获取所有商品
    }
});

这样,你在获取所有商品信息的时候可以调用 Loading Cache 的 get 方法,就可以优先从本地缓存中获取商品信息,如果本地缓存不存在,会使用 CacheLoader 中的逻辑从数据库中加载所有的商品。

由于本地缓存是部署在应用服务器中,而我们应用服务器通常会部署多台,当数据更新时,我们不能确定哪台服务器本地中了缓存,更新或者删除所有服务器的缓存不是一个好的选择,所以我们通常会等待缓存过期。因此,这种缓存的有效期很短,通常为分钟或者秒级别,以避免返回前端脏数据。

缓存的不足

通过了解上面的内容,你不难发现,缓存的主要作用是提升访问速度,从而能够抗住更高的并发。那么,缓存是不是能够解决一切问题?显然不是。事物都是具有两面性的,缓存也不例外,我们要了解它的优势的同时也需要了解它有哪些不足,从而扬长避短,将它的作用发挥到最大。

首先,缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性。 这是因为缓存毕竟会受限于存储介质不可能缓存所有数据,那么当数据有热点属性的时候才能保证一定的缓存命中率。比如说类似微博、朋友圈这种 20% 的内容会占到 80% 的流量。所以,一旦当业务场景读少写多时或者没有明显热点时,比如在搜索的场景下,每个人搜索的词都会不同,没有明显的热点,那么这时缓存的作用就不明显了。

其次,缓存会给整体系统带来复杂度,并且会有数据不一致的风险。 当更新数据库成功,更新缓存失败的场景下,缓存中就会存在脏数据。对于这种场景,我们可以考虑使用较短的过期时间或者手动清理的方式来解决。

再次,之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的。 因此,我们在使用缓存的时候要做数据存储量级的评估,对于可预见的需要消耗极大存储成本的数据,要慎用缓存方案。同时,缓存一定要设置过期时间,这样可以保证缓存中的会是热点数据。

最后,缓存会给运维也带来一定的成本, 运维需要对缓存组件有一定的了解,在排查问题的时候也多了一个组件需要考虑在内。

虽然有这么多的不足,但是缓存对于性能的提升是毋庸置疑的,我们在做架构设计的时候也需要把它考虑在内,只是在做具体方案的时候需要对缓存的设计有更细致的思考,才能最大化的发挥缓存的优势。

课程小结

这节课我带你了解了缓存的定义,常见缓存的分类以及缓存的不足。我想跟你强调的重点有以下几点:

  • 缓存可以有多层,比如上面提到的静态缓存处在负载均衡层,分布式缓存处在应用层和数据库层之间,本地缓存处在应用层。我们需要将请求尽量挡在上层,因为越往下层,对于并发的承受能力越差;
  • 缓存命中率是我们对于缓存最重要的一个监控项,越是热点的数据,缓存的命中率就越高。

你还需要理解的是,缓存不仅仅是一种组件的名字,更是一种设计思想,你可以认为任何能够加速读请求的组件和设计方案都是缓存思想的体现。而这种加速通常是通过两种方式来实现:

  • 使用更快的介质,比方说课程中提到的内存;
  • 缓存复杂运算的结果,比方说前面 TLB 的例子就是缓存地址转换的结果。

那么,当你在实际工作中碰到“慢”的问题时,缓存就是你第一时间需要考虑的。

12 - CH13-缓存-读写策略

上节课,我带你了解了缓存的定义、分类以及不足,你现在应该对缓存有了初步的认知。从今天开始,我将带你了解一下使用缓存的正确姿势,比如缓存的读写策略是什么样的,如何做到缓存的高可用以及如何应对缓存穿透。通过了解这些内容,你会对缓存的使用有深刻的认识,这样在实际工作中就可以在缓存使用上游刃有余了。

今天,我们先讲讲缓存的读写策略。你可能觉得缓存的读写很简单,只需要优先读缓存,缓存不命中就从数据库查询,查询到了就回种缓存。实际上,针对不同的业务场景,缓存的读写策略也是不同的。

而我们在选择策略时也需要考虑诸多的因素,比如说,缓存中是否有可能被写入脏数据,策略的读写性能如何,是否存在缓存命中率下降的情况等等。接下来,我就以标准的 缓存 + 数据库 的场景为例,带你剖析经典的缓存读写策略以及它们适用的场景。这样一来,你就可以在日常的工作中根据不同的场景选择不同的读写策略。

Cache Aside(旁路缓存)策略

我们来考虑一种最简单的业务场景,比方说在你的电商系统中有一个用户表,表中只有 ID 和年龄两个字段,缓存中我们以 ID 为 Key 存储用户的年龄信息。那么当我们要把 ID 为 1 的用户的年龄从 19 变更为 20,要如何做呢?

你可能会产生这样的思路: 先更新数据库中 ID 为 1 的记录,再更新缓存中 Key 为 1 的数据。

NAME

这个思路会造成缓存和数据库中的数据不一致。 比如,A 请求将数据库中 ID 为 1 的用户年龄从 19 变更为 20,与此同时,请求 B 也开始更新 ID 为 1 的用户数据,它把数据库中记录的年龄变更为 21,然后变更缓存中的用户年龄为 21。紧接着,A 请求开始更新缓存数据,它会把缓存中的年龄变更为 20。此时,数据库中用户年龄是 21,而缓存中的用户年龄却是 20。

NAME

为什么产生这个问题呢? 因为变更数据库和变更缓存是两个独立的操作,而我们并没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不同造成数据的不一致。

另外,直接更新缓存还存在另外一个问题就是丢失更新。还是以我们的电商系统为例,假如电商系统中的账户表有三个字段:ID、户名和金额,这个时候缓存中存储的就不只是金额信息,而是完整的账户信息了。当更新缓存中账户金额时,你需要从缓存中查询完整的账户数据,把金额变更后再写入到缓存中。

这个过程中也会有并发的问题,比如说原有金额是 20,A 请求从缓存中读到数据,并且把金额加 1,变更成 21,在未写入缓存之前又有请求 B 也读到缓存的数据后把金额也加 1,也变更成 21,两个请求同时把金额写回缓存,这时缓存里面的金额是 21,但是我们实际上预期是金额数加 2,这也是一个比较大的问题。

那我们要如何解决这个问题呢? 其实,我们可以在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

NAME

这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据 以数据库中的数据为准,缓存中的数据是按需加载的 。它可以分为读策略和写策略,

读策略的步骤是:

  • 从缓存中读取数据;
  • 如果缓存命中,则直接返回数据;
  • 如果缓存不命中,则从数据库中查询数据;
  • 查询到数据后,将数据写入到缓存中,并且返回给用户。

写策略的步骤是:

  • 更新数据库中的记录;
  • 删除缓存记录。

你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢? 答案是不行的, 因为这样也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。

假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21,这就造成了缓存和数据库的不一致。

NAME

那么像 Cache Aside 策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上还是有缺陷的。假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存和数据库数据不一致。

NAME

不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入 ,所以在实际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载数据,所以不会出现这种不一致的情况。

Cache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。 比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时 ,会出现因为主从延迟所以读不到用户信息的情况。

而解决这个问题的办法 恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况

Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。 如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

  1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
  2. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。

TIP

笔者感觉上面的一些点感觉很混乱,对缓存命中率有要求,根本原因是写入频繁,加锁单线程更新就能保证了?

这里细说一下: 数据更新:发送更新缓存指令给 缓存组件,由缓存组件对同一个 key 是加锁更新的,它可以进行一个时间窗口的优化很厚再更新

读数据:发现没有缓存? 则发送读缓存指令给缓存,然后自己这边等待缓存数据的更新,直到读到后或则超时返回。

当然了,除了这个策略,在计算机领域还有其他几种经典的缓存策略,它们也有各自适用的使用场景。

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

这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据 。这就好比你在汇报工作的时候只对你的直接上级汇报,再由你的直接上级汇报给他的上级,你是不能越级汇报的。

Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做 Write Miss(写失效)

一般来说,我们可以选择两种 Write Miss 方式:

  • Write Allocate(按写分配)

    做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;

  • No-write allocate(不按写分配)

    做法是不写入缓存中,而是直接更新到数据库中

在 Write Through 策略中,我们一般选择 No-write allocate 方式,原因是无论采用哪种 Write Miss 方式,我们都需要同步将数据更新到数据库中,而 No-write allocate 方式相比 Write Allocate 还减少了一次缓存的写入,能够提升写入的性能。

Read Through 策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。

下面是 Read Through/Write Through 策略的示意图:

NAME

Read Through/Write Through 策略的特点是 由缓存节点而非用户来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库,或者自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略,比如说在上一节中提到的本地缓存 Guava Cache 中的 Loading Cache 就有 Read Through 策略的影子。

我们看到 Write Through 策略中写数据库是同步的 ,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。那么我们可否异步地更新数据库?这就是我们接下来要提到的 Write Back 策略。

Write Back(写回)策略

这个策略的核心思想是 在写入数据时只写入缓存,并且把缓存块儿标记为 「脏」 的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。

需要注意的是,Write Miss 的情况下,我们采用的是 Write Allocate 的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将 Write back 策略的示意图放在了下面:

NAME

如果使用 Write Back 策略的话,读的策略也有一些变化了:

  1. 我们在读取缓存时如果发现 缓存命中则直接返回缓存数据

  2. 如果缓存不命中则寻找一个可用的缓存块儿

    如果这个缓存块儿是 「脏」的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿

    如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。

NAME

发现了吗? 其实这种策略不能被应用到我们常用的数据库和缓存的场景中,它是计算机体系结构中的设计,比如我们在向磁盘中写数据时采用的就是这种策略。无论是操作系统层面的 Page Cache,还是日志的异步刷盘,亦或是消息队列中消息的异步写入磁盘,大多采用了这种策略。因为这个策略在性能上的优势毋庸置疑,它避免了直接写磁盘造成的随机写问题,毕竟写内存和写磁盘的随机 I/O 的延迟相差了几个数量级呢。

但因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏块儿数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。

TIP

笔者疑问:在读的时候,缓存不命中,还可以去找缓存块?都没有命中了,这里不明白.

计算机体系结构中的策略,它的完整读策略是这样的:如果缓存命中,则直接返回;如果缓存不命中,则重新找一个缓存块儿,如果这个缓存块儿是脏的,那么写入后端存储,并且把后端存储中的数据加载到缓存中;如果不是脏的,那么就把后端存储中的数据加载到缓存,然后标记缓存非脏

所以说这里介绍的,不要看成是普通的一个 key 就对应这个 key 应该有的缓存数据

当然,你依然可以在一些场景下使用这个策略,在使用时,我想给你的落地建议是: 你在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。比如说,你在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘 I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。

课程小结

本节课,我主要带你了解了缓存使用的几种策略,以及每种策略适用的使用场景是怎样的。我想让你掌握的重点是:

  1. Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。
  2. Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;
  3. Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。

而且,你还需要了解,我们今天提到的策略都是标准的使用姿势,在实际开发过程中需要结合实际的业务特点灵活使用甚至加以改造。这些业务特点包括但不仅限于:整体的数据量级情况,访问的读写比例的情况,对于数据的不一致时间的容忍度,对于缓存命中率的要求等等。理论结合实践,具体情况具体分析,你才能得到更好的解决方案。

13 - CH14-缓存-高可用

前面几节课,我带你了解了缓存的原理、分类以及常用缓存的使用技巧。我们开始用缓存承担大部分的读压力,从而缓解数据库的查询压力,在提升性能的同时保证系统的稳定性。这时,你的电商系统整体的架构演变成下图的样子:

NAME

我们在 Web 层和数据库层之间增加了缓存层,请求会首先查询缓存,只有当缓存中没有需要的数据时才会查询数据库。

在这里,你需要关注缓存命中率这个指标(缓存命中率 = 命中缓存的请求数 / 总请求数)。一般来说,在你的电商系统中,核心缓存的命中率需要维持在 99% 甚至是 99.9%,哪怕下降 1%,系统都会遭受毁灭性的打击。

这绝不是危言耸听,我们来计算一下。假设系统的 QPS 是 10000/s,每次调用会访问 10 次缓存或者数据库中的数据,那么当缓存命中率仅仅减少 1%,数据库每秒就会增加 10000 * 10 * 1% = 1000 次请求。而一般来说我们单个 MySQL 节点的读请求量峰值就在 1500/s 左右,增加的这 1000 次请求很可能会给数据库造成极大的冲击。

命中率仅仅下降 1% 造成的影响就如此可怕,更不要说缓存节点故障了。而图中单点部署的缓存节点就成了整体系统中最大的隐患,那我们要如何来解决这个问题,提升缓存的可用性呢?

我们可以通过部署多个节点,同时设计一些方案让这些节点互为备份。这样,当某个节点故障时,它的备份节点可以顶替它继续提供服务。 而这些方案就是我们本节课的重点:分布式缓存的高可用方案。

在我的项目中,我主要选择的方案有 客户端方案、中间代理层方案和服务端方案 三大类:

  • 客户端方案 就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。
  • 中间代理层方案 是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。
  • 服务端方案 就是 Redis 2.4 版本后提出的 Redis Sentinel 方案。

掌握这些方案可以帮助你,抵御部分缓存节点故障导致的,缓存命中率下降的影响,增强你的系统的鲁棒性。

客户端方案

在客户端方案中,你需要关注缓存的写和读两个方面:

  • 写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片;
  • 读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。

下面我就带你一起详细地看一下到底要怎么做。

1. 缓存数据如何分片

单一的缓存节点受到机器内存、网卡带宽和单节点请求量的限制,不能承担比较高的并发,因此我们考虑将数据分片,依照分片算法将数据打散到多个不同的节点上,每个节点上存储部分数据。

这样在某个节点故障的情况下,其他节点也可以提供服务,保证了一定的可用性。这就好比不要把鸡蛋放在同一个篮子里,这样一旦一个篮子掉在地上,摔碎了,别的篮子里还有没摔碎的鸡蛋,不至于一个不剩。

NAME

这个算法最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。 所以我建议你, 如果采用这种方法,最好建立在你对于这组缓存命中率下降不敏感,比如下面还有另外一层缓存来兜底的情况下。

当然了,用一致性 Hash 算法可以很好地解决增加和删减节点时,命中率下降的问题。 在这个算法中,我们将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上 行走,遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面,Key 1 和 Key 2 会落入到 Node 1 中,Key 3、Key 4 会落入到 Node 2 中,Key 5 落入到 Node 3 中,Key 6 落入到 Node 4 中。

NAME

这时如果在 Node 1 和 Node 2 之间增加一个 Node 5,你可以看到原本命中 Node 2 的 Key 3 现在命中到 Node 5,而其它的 Key 都没有变化;同样的道理,如果我们把 Node 3 从集群中移除,那么只会影响到 Key 5 。所以你看, 在增加和删除节点时,只有少量的 Key 会「漂移」到其它节点上, 而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。

NAME

不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:

  • 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
  • 一致性 Hash 算法的脏数据问题。

极端情况下,比如一个有三个节点 A、B、C 承担整体的访问,每个节点的访问量平均,A 故障后,B 将承担双倍的压力(A 和 B 的全部请求),当 B 承担不了流量 Crash 后,C 也将因为要承担原先三倍的流量而 Crash,这就造成了整体缓存系统的雪崩。

说到这儿,你可能觉得很可怕,但也不要太担心, 我们程序员就是要能够创造性地解决各种问题,所以你可以在一致性 Hash 算法中引入虚拟节点的概念。

它将一个缓存节点计算多个 Hash 值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key 将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。

其次,就是一致性 Hash 算法的脏数据问题。为什么会产生脏数据呢? 比方说,在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。

所以,在使用一致性 Hash 算法时一定要设置缓存的过期时间, 这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。

NAME

很显然,数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它也让缓存的使用更加复杂。在 MultiGet(批量获取)场景下,单个节点的访问量并没有减少,同时节点数太多会造成缓存访问的 SLA(即「服务等级协议」,SLA 代表了网站服务可用性)得不到很好的保证,因为根据木桶原则,SLA 取决于最慢、最坏的节点的情况,节点数过多也会增加出问题的概率, 因此我推荐 4 到 6 个节点为佳。

2. Memcached 的主从机制

Redis 本身支持主从的部署方式,但是 Memcached 并不支持,所以我们今天主要来了解一下 Memcached 的主从机制是如何在客户端实现的。

在之前的项目中,我就遇到了单个主节点故障导致数据穿透的问题,这时我为每一组 Master 配置一组 Slave,更新数据时主从同步更新。读取时,优先从 Slave 中读数据,如果读取不到数据就穿透到 Master 读取,并且将数据回种到 Slave 中以保持 Slave 数据的热度。

主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性。

NAME

3. 多副本

其实,主从方式已经能够解决大部分场景的问题,但是对于极端流量的场景下,一组 Slave 通常来说并不能完全承担所有流量,Slave 网卡带宽可能成为瓶颈。

为了解决这个问题,我们考虑在 Master/Slave 之前增加一层副本层,整体架构是这样的:

NAME

在这个方案中,当客户端发起查询请求时,请求首先会先从多个副本组中选取一个副本组发起查询,如果查询失败,就继续查询 Master/Slave,并且将查询的结果回种到所有副本组中,避免副本组中脏数据的存在。

基于成本的考虑,每一个副本组容量比 Master 和 Slave 要小,因此它只存储了更加热的数据。在这套架构中,Master 和 Slave 的请求量会大大减少,为了保证它们存储数据的热度,在实践中我们会把 Master 和 Slave 作为一组副本组使用。

中间代理层方案

虽然客户端方案已经能解决大部分的问题,但是只能在单一语言系统之间复用。例如微博使用 Java 语言实现了这么一套逻辑,我使用 PHP 就难以复用,需要重新写一套,很麻烦。 而中间代理层的方案就可以解决这个问题。 你可以将客户端解决方案的经验移植到代理层中,通过通用的协议(如 Redis 协议)来实现在其他语言中的复用。

如果你来自研缓存代理层,你就可以将客户端方案中的高可用逻辑封装在代理层代码里面,这样用户在使用你的代理层的时候就不需要关心缓存的高可用是如何做的,只需要依赖你的代理层就好了。

除此以外,业界也有很多中间代理层方案,比如 Facebook 的 Mcrouter ,Twitter 的 Twemproxy,豌豆荚的 Codis 。它们的原理基本上可以由一张图来概括:

NAME

看这张图你有什么发现吗? 所有缓存的 读写请求 都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑,不同的开源中间代理层方案中使用的高可用策略各有不同。比如在 Twemproxy 中,Proxy 保证在某一个 Redis 节点挂掉之后会把它从集群中移除,后续的请求将由其他节点来完成;而 Codis 的实现略复杂,它提供了一个叫 Codis Ha 的工具来实现自动从节点提主节点,在 3.2 版本之后换做了 Redis Sentinel 方式,从而实现 Redis 节点的高可用。

服务端方案

Redis 在 2.4 版本中提出了 Redis Sentinel 模式来解决主从 Redis 部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性,整体的架构如下图所示:

NAME

Redis Sentinel 也是集群部署的,这样可以避免 Sentinel 节点挂掉造成无法自动故障恢复的问题,每一个 Sentinel 节点都是无状态的。在 Sentinel 中会配置 Master 的地址,Sentinel 会时刻监控 Master 的状态,当发现 Master 在配置的时间间隔内无响应,就认为 Master 已经挂了,Sentinel 会从从节点中选取一个提升为主节点,并且把所有其他的从节点作为新主的从节点。Sentinel 集群内部在仲裁的时候,会根据配置的值来决定当有几个 Sentinel 节点认为主挂掉可以做主从切换的操作,也就是集群内部需要对缓存节点的状态达成一致才行。

Redis Sentinel 不属于代理层模式,因为对于缓存的写入和读取请求不会经过 Sentinel 节点。Sentinel 节点在架构上和主从是平级的,是作为管理者存在的, 所以可以认为是在服务端提供的一种高可用方案。

课程小结

这就是今天分享的全部内容了,我们一起来回顾一下重点:

分布式缓存的高可用方案主要有三种,首先是客户端方案,一般也称为 Smart Client。我们通过制定一些数据分片和数据读写的策略,可以实现缓存高可用。这种方案的好处是性能没有损耗,缺点是客户端逻辑复杂且在多语言环境下不能复用。

其次,中间代理方案在客户端和缓存节点之间增加了中间层,在性能上会有一些损耗,在代理层会有一些内置的高可用方案,比如 Codis 会使用 Codis Ha 或者 Sentinel。

最后,服务端方案依赖于组件的实现,Memcached 就只支持单机版没有分布式和 HA 的方案,而 Redis 在 2.4 版本提供了 Sentinel 方案可以自动进行主从切换。服务端方案会在运维上增加一些复杂度。

总体而言,分布式缓存的三种方案各有所长,有些团队可能在开发过程中已经积累了 Smart Client 上的一些经验;而有些团队在 Redis 运维上经验丰富,就可以推进 Sentinel 方案;有些团队在存储研发方面有些积累,就可以推进中间代理层方案,甚至可以自研适合自己业务场景的代理层组件,具体的选择还是要看团队的实际情况而定。

14 - CH15-缓存-穿透

我用三节课的时间,带你深入了解了缓存,你应该知道,对于缓存来说,命中率是它的生命线。

在低缓存命中率的系统中,大量查询商品信息的请求会穿透缓存到数据库,因为数据库对于并发的承受能力是比较脆弱的。一旦数据库承受不了用户大量刷新商品页面、定向搜索衣服信息,就会导致查询变慢,导致大量的请求阻塞在数据库查询上,造成应用服务器的连接和线程资源被占满,最终导致你的电商系统崩溃。

一般来说,我们的核心缓存的命中率要保持在 99% 以上,非核心缓存的命中率也要尽量保证在 90%,如果低于这个标准,那么你可能就需要优化缓存的使用方式了。

既然缓存的穿透会带来如此大的影响,那么我们该如何减少它的发生呢?本节课,我就带你全面探知,面对缓存穿透时,我们到底有哪些应对措施。不过在此之前,你需要了解「到底什么是缓存穿透」,只有这样,才能更好地考虑如何设计方案解决它。

什么是缓存穿透

缓存穿透其实是指从缓存中没有查到数据,而不得不从后端系统(比如数据库)中查询的情况。你可以把数据库比喻为手机,它是经受不了太多的划痕和磕碰的,所以你需要给它贴个膜再套个保护壳,就能对手机起到一定的保护作用了。

不过,少量的缓存穿透不可避免,对系统也是没有损害的,主要有几点原因:

  • 一方面,互联网系统通常会面临极大数据量的考验,而缓存系统在容量上是有限的,不可能存储系统所有的数据,那么在查询未缓存数据的时候就会发生缓存穿透。
  • 另一方面,互联网系统的数据访问模型一般会遵从 80/20 原则80/20 原则 又称为帕累托法则,是意大利经济学家帕累托提出的一个经济学的理论。它是指在一组事物中,最重要的事物通常只占 20%,而剩余的 80% 的事物确实不重要的。把它应用到数据访问的领域,就是我们会经常访问 20% 的热点数据,而另外的 80% 的数据则不会被经常访问。比如你买了很多衣服,很多书,但是其实经常穿的,经常看的,可能也就是其中很小的一部分。

既然缓存的容量有限,并且大部分的访问只会请求 20% 的热点数据,那么理论上说,我们只需要在有限的缓存空间里存储 20% 的热点数据就可以有效地保护脆弱的后端系统了,也就可以放弃缓存另外 80% 的非热点数据了。所以,这种少量的缓存穿透是不可避免的,但是对系统是没有损害的。

那么什么样的缓存穿透对系统有害呢?答案是大量的穿透请求超过了后端系统的承受范围,造成了后端系统的崩溃。如果把少量的请求比作毛毛细雨,那么一旦变成倾盆大雨,引发洪水,冲倒房屋,肯定就不行了。

产生这种大量穿透请求的场景有很多,接下来,我就带你解析这几种场景以及相应的解决方案。

缓存穿透的解决方案

先来考虑这样一种场景:在你的电商系统的用户表中,我们需要通过用户 ID 查询用户的信息,缓存的读写策略采用 Cache Aside 策略。

那么,如果要读取一个用户表中未注册的用户,会发生什么情况呢?按照这个策略,我们会先读缓存,再穿透读数据库。由于用户并不存在,所以缓存和数据库中都没有查询到数据,因此也就不会向缓存中回种数据(也就是向缓存中设置值的意思),这样当再次请求这个用户数据的时候还是会再次穿透到数据库。在这种场景下,缓存并不能有效地阻挡请求穿透到数据库上,它的作用就微乎其微了。

那如何解决缓存穿透呢?一般来说我们会有两种解决方案: 回种空值以及使用布隆过滤器。

我们先来看看第一种方案。

我们先来看看第一种方案。

回种空值

回顾上面提到的场景,你会发现最大的问题在于数据库中并不存在用户的数据,这就造成无论查询多少次,数据库中永远都不会存在这个用户的数据,穿透永远都会发生。

类似的场景还有一些: 比如由于代码的 bug 导致查询数据库的时候抛出了异常,这样可以认为从数据库查询出来的数据为空,同样不会回种缓存。

那么,当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。 下面是这个流程的伪代码:

Object nullValue = new Object();
try {
  Object valueFromDB = getFromDB(uid); // 从数据库中查询数据
  if (valueFromDB == null) {
    cache.set(uid, nullValue, 10);   // 如果从数据库中查询到空值,就把空值写入缓存,设置较短的超时时间
  } else {
    cache.set(uid, valueFromDB, 1000);
  }
} catch(Exception e) {
  cache.set(uid, nullValue, 10);
}

回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。

所以这个方案,我建议你在使用的时候应该评估一下缓存容量是否能够支撑。 如果需要大量的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时你可以考虑使用布隆过滤器。

使用布隆过滤器

1970 年布隆提出了一种布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。 它的基本思路如下:

我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。

下图是布隆过滤器示意图,我来带你分析一下图内的信息。

NAME

A、B、C 等元素组成了一个集合,元素 D 计算出的 Hash 值所对应的的数组中值是 1,所以可以认为 D 也在集合中。而 F 在数组中的值是 0,所以 F 不在数组中。

那么我们如何使用布隆过滤器来解决缓存穿透的问题呢?

还是以存储用户信息的表为例进行讲解。首先,我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。

新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数组中,相应位置的值。那么当我们需要查询某一个用户的信息时,我们首先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。

NAME

布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1),是常量值。在空间上,相对于其他数据结构它也有很大的优势,比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。

不过,任何事物都有两面性,布隆过滤器也不例外, 它主要有两个缺陷:

  1. 它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中;
  2. 不支持删除元素。

关于第一个缺陷,主要是 Hash 算法的问题。 因为布隆过滤器是由一个二进制数组和一个 Hash 算法组成的,Hash 算法存在着一定的碰撞几率。Hash 碰撞的含义是不同的输入值经过 Hash 运算后得到了相同的 Hash 结果。

本来,Hash 的含义是不同的输入,依据不同的算法映射成独一无二的固定长度的值,也就是我输入字符串 1,根据 CRC32 算法,值是 2212294583。但是现实中 Hash 算法的输入值是无限的,输出值的值空间却是固定的,比如 16 位的 Hash 值的值空间是 65535,那么它的碰撞几率就是 1/65535,即如果输入值的个数超过 65535 就一定会发生碰撞。

那么你可能会问为什么不映射成更长的 Hash 值呢?

因为更长的 Hash 值会带来更高的存储成本和计算成本。即使使用 32 位的 Hash 算法,它的值空间长度是 2 的 32 次幂减一,约等于 42 亿,用来映射 20 亿的用户数据,碰撞几率依然有接近 50%。(笔者疑问:42 亿不能装下 20 亿?)

Hash 的碰撞就造成了两个用户 ID ,A 和 B 会计算出相同的 Hash 值,那么如果 A 是注册的用户,它的 Hash 值对应的数组中的值是 1,那么 B 即使不是注册用户,它在数组中的位置和 A 是相同的,对应的值也是 1, 这就产生了误判。

布隆过滤器的误判有一个特点,就是它只会出现 false positive 的情况。这是什么意思呢?当布隆过滤器判断元素在集合中时,这个元素可能不在集合中。但是一旦布隆过滤器判断这个元素不在集合中时,它一定不在集合中。 这一点非常适合解决缓存穿透的问题。 为什么呢?

你想,如果布隆过滤器会将集合中的元素判定为不在集合中,那么我们就不确定,被布隆过滤器判定为不在集合中的元素,是不是在集合中。假设在刚才的场景中,如果有大量查询未注册的用户信息的请求存在,那么这些请求到达布隆过滤器之后,即使布隆过滤器判断为不是注册用户,那么我们也不确定它是不是真的不是注册用户,那么就还是需要去数据库和缓存中查询,这就使布隆过滤器失去了价值。

所以你看,布隆过滤器虽然存在误判的情况,但是还是会减少缓存穿透的情况发生,只是我们需要尽量减少误判的几率,这样布隆过滤器的判断正确的几率更高,对缓存的穿透也更少。 一个解决方案是:

使用多个 Hash 算法为元素计算出多个 Hash 值,只有所有 Hash 值对应的数组中的值都为 1 时,才会认为这个元素在集合中。

布隆过滤器不支持删除元素的缺陷也和 Hash 碰撞有关。 给你举一个例子,假如两个元素 A 和 B 都是集合中的元素,它们有相同的 Hash 值,它们就会映射到数组的同一个位置。这时我们删除了 A,数组中对应位置的值也从 1 变成 0,那么在判断 B 的时候发现值是 0,也会判断 B 是不在集合中的元素,就会得到错误的结论。

那么我是怎么解决这个问题的呢? 我会让数组中不再只有 0 和 1 两个值,而是存储一个计数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删除了就把这个值从 2 改为 1。这个方案中的数组不再存储 bit 位,而是存储数值,也就会增加空间的消耗。所以,你要依据业务场景来选择是否能够使用布隆过滤器, 比如像是注册用户的场景下,因为用户删除的情况基本不存在,所以还是可以使用布隆过滤器来解决缓存穿透的问题的。

讲了这么多,关于布隆过滤器的使用上,我也给你几个建议:

  1. 选择多个 Hash 函数计算多个 Hash 值,这样可以减少误判的几率;
  2. 布隆过滤器会消耗一定的内存空间,所以在使用时需要评估你的业务场景下需要多大的内存,存储的成本是否可以接受。

总的来说,回种空值和布隆过滤器 是解决缓存穿透问题的两种最主要的解决方案,但是它们也有各自的适用场景,并不能解决所有问题。比方说当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫做 dog-pile effect(狗桩效应),

这是典型的缓存并发穿透的问题, 那么,我们如何来解决这个问题呢? 解决狗桩效应的思路是尽量地减少缓存穿透后的并发,方案也比较简单:

  1. 在代码中,控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。
  2. 通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库。

分布式锁的方式也比较简单,比方说 ID 为 1 的用户是一个热点用户,当他的用户信息缓存失效后,我们需要从数据库中重新加载数据时,先向 Memcached 中写入一个 Key 为 lock.1 的缓存项,然后去数据库里面加载数据,当数据加载完成后再把这个 Key 删掉。这时,如果另外一个线程也要请求这个用户的数据,它发现缓存中有 Key 为“ lock.1 的缓存,就认为目前已经有线程在加载数据库中的值到缓存中了,它就可以重新去缓存中查询数据,不再穿透数据库了。

课程小结

本节课,我带你了解了一些解决缓存穿透的方案,你可以在发现自己的缓存系统命中率下降时,从中得到一些借鉴的思路。我想让你明确的重点是:

  1. 回种空值是一种最常见的解决思路,实现起来也最简单,如果评估空值缓存占据的缓存空间可以接受,那么可以优先使用这种方案;
  2. 布隆过滤器会引入一个新的组件,也会引入一些开发上的复杂度和运维上的成本。所以只有在存在海量查询数据库中,不存在数据的请求时才会使用,在使用时也要关注布隆过滤器对内存空间的消耗;
  3. 对于极热点缓存数据穿透造成的 狗桩效应,可以通过设置分布式锁或者后台线程定时加载的方式来解决。

除此之外,你还需要了解的是,数据库是一个脆弱的资源,它无论是在扩展性、性能还是承担并发的能力上,相比缓存都处于绝对的劣势,所以我们解决缓存穿透问题的 核心目标在于减少对于数据库的并发请求。 了解了这个核心的思想,也许你还会在日常工作中找到其他更好的解决缓存穿透问题的方案。

15 - CH16-缓存-CDN

前面几节课,我带你了解了缓存的定义以及常用缓存的使用姿势,现在,你应该对包括本地缓存、分布式缓存等缓存组件的适用场景和使用技巧有了一定了解了。结合在14 讲中我提到的客户端高可用方案,你会将单个缓存节点扩展为高可用的缓存集群,现在,你的电商系统架构演变成了下面这样:

NAME

在这个架构中我们使用分布式缓存对动态请求数据的读取做了加速,但是在我们的系统中 存在着大量的静态资源请求:

  1. 对于移动 APP 来说,这些静态资源主要是图片、视频和流媒体信息。
  2. 对于 Web 网站来说,则包括了 JavaScript 文件,CSS 文件,静态 HTML 文件等等。

具体到你的电商系统来说,商品的图片,介绍商品使用方法的视频等等静态资源,现在都放在了 Nginx 等 Web 服务器上,它们的读请求量极大,并且对访问速度的要求很高,并且占据了很高的带宽,这时会出现访问速度慢,带宽被占满影响动态请求的问题, 那么你就需要考虑如何针对这些静态资源进行读加速了。

静态资源加速的考虑点

你可能会问:「我们是否也可以使用分布式缓存来解决这个问题呢?」答案是否定的。一般来说,图片和视频的大小会在几兆到几百兆之间不等,如果我们的应用服务器和分布式缓存都部署在北京的机房里,这时一个杭州的用户要访问缓存中的一个视频,那这个视频文件就需要从北京传输到杭州,期间会经过多个公网骨干网络,延迟很高,会让用户感觉视频打开很慢,严重影响到用户的使用体验。

所以,静态资源访问的关键点是 就近访问, 即北京用户访问北京的数据,杭州用户访问杭州的数据,这样才可以达到性能的最优。

你可能会说:「那我们在杭州也自建一个机房,让用户访问杭州机房的数据就好了呀。」可用户遍布在全国各地,有些应用可能还有国外的用户,我们不可能在每个地域都自建机房,这样成本太高了。

另外,单个视频和图片等静态资源很大,并且访问量又极高,如果使用业务服务器和分布式缓存来承担这些流量,无论是对于内网还是外网的带宽都会是很大的考验。

所以我们考虑在业务服务器的上层,增加一层特殊的缓存,用来承担绝大部分对于静态资源的访问,这一层特殊缓存的节点需要遍布在全国各地,这样可以让用户选择最近的节点访问。缓存的命中率也需要一定的保证,尽量减少访问资源存储源站的请求数量(回源请求)。 这一层缓存就是我们这节课的重点:CDN。

CDN 的关键技术

CDN(Content Delivery Network/Content Distribution Network,内容分发网络)。简单来说,CDN 就是将静态的资源分发到,位于多个地理位置机房中的服务器上,因此它能很好地解决数据就近访问的问题,也就加快了静态资源的访问速度。

在大中型公司里面,CDN 的应用非常的普遍,大公司为了提供更稳定的 CDN 服务会选择自建 CDN,而大部分公司基于成本的考虑还是会选择专业的 CDN 厂商,网宿、阿里云、腾讯云、蓝汛等等,其中网宿和蓝汛是老牌的 CDN 厂商,阿里云和腾讯云是云厂商提供的服务,如果你的服务部署在云上可以选择相应云厂商的 CDN 服务,这些 CDN 厂商都是现今行业内比较主流的。

对于 CDN 来说,你可能已经从运维的口中听说过,并且也了解了它的作用。但是当让你来配置 CDN 或者是排查 CDN 方面的问题时,你就有可能因为不了解它的原理而束手无策了。

所以,我先来带你了解一下,要搭建一个 CDN 系统需要考虑哪两点:

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

下面我就带你具体了解一下 CDN 系统是如何实现加速用户对于静态资源的请求的。

1. 如何让用户的请求到达 CDN 节点

首先,我们考虑一下如何让用户的请求到达 CDN 节点,你可能会觉得,这很简单啊,只需要告诉用户 CDN 节点的 IP 地址,然后请求这个 IP 地址上面部署的 CDN 服务就可以了啊。 但是这样会有一个问题: 就是我们使用的是第三方厂商的 CDN 服务,CDN 厂商会给我们一个 CDN 的节点 IP,比如说这个 IP 地址是「111.202.34.130」,那么我们的电商系统中的图片的地址很可能是这样的:「http://111.202.34.130/1.jpg」, 这个地址是要存储在数据库中的。

那么如果这个节点 IP 发生了变更怎么办?或者我们如果更改了 CDN 厂商怎么办?是不是要修改所有的商品的 url 域名呢?这就是一个比较大的工作量了。所以,我们要做的事情是 将第三方厂商提供的 IP 隐藏起来,给到用户的最好是一个本公司域名的子域名。

那么如何做到这一点呢? 这就需要依靠 DNS 来帮我们解决域名映射的问题了。

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

  • 一种叫做 A 记录,返回的是域名对应的 IP 地址;

  • 另一种是 CNAME 记录,返回的是另一个域名

    也就是说当前域名的解析要跳转到另一个域名的解析上,实际上 www.baidu.com 域名的解析结果就是一个 CNAME 记录,域名的解析被跳转到 www.a.shifen.com 上了,我们正是利用 CNAME 记录来解决域名映射问题的,具体是怎么解决的呢?我给你举个例子。

比如你的公司的一级域名叫做 example.com,那么你可以给你的图片服务的域名定义为 img.example.com,然后将这个域名的解析结果的 CNAME 配置到 CDN 提供的域名上。比如 uclound 可能会提供一个域名是 80f21f91.cdn.ucloud.com.cn 这个域名。这样你的电商系统使用的图片地址可以是 http://img.example.com/1.jpg

用户在请求这个地址时,DNS 服务器会将域名解析到 80f21f91.cdn.ucloud.com.cn 域名上,然后再将这个域名解析为 CDN 的节点 IP,这样就可以得到 CDN 上面的资源数据了。

不过,这里面有一个问题: 因为域名解析过程是分级的,每一级有专门的域名服务器承担解析的职责,所以,域名的解析过程有可能需要跨越公网做多次 DNS 查询,在性能上是比较差的。

NAME

从 「域名分级解析示意图」中你可以看出 DNS 分为很多种,有根 DNS,顶级 DNS 等等。除此之外还有两种 DNS 需要特别留意:

  • 一种是 Local DNS,它是由你的运营商提供的 DNS,一般域名解析的第一站会到这里;
  • 另一种是权威 DNS,它的含义是自身数据库中存储了这个域名对应关系的 DNS。

下面我以 www.baidu.com 这个域名为例给你简单介绍一下域名解析的过程:

  • 一开始,域名解析请求先会检查本机的 hosts 文件,查看是否有 www.baidu.com 对应的 IP;
  • 如果没有的话,就请求 Local DNS 是否有域名解析结果的缓存,如果有就返回,标识是从非权威 DNS 返回的结果;
  • 如果没有,就开始 DNS 的迭代查询:
    1. 先请求根 DNS,根 DNS 返回顶级 DNS(.com)的地址;
    2. 再请求 .com 顶级 DNS,得到 baidu.com 的域名服务器地址;
    3. 再从 baidu.com 的域名服务器中查询到 www.baidu.com 对应的 IP 地址,返回这个 IP 地址的同时,标记这个结果是来自于权威 DNS 的结果,同时写入 Local DNS 的解析结果缓存,这样下一次的解析同一个域名就不需要做 DNS 的迭代查询了。

经过了向多个 DNS 服务器做查询之后,整个 DNS 的解析的时间有可能会到秒级别, 那么我们如何来解决这个性能问题呢?

一个解决的思路是: 在 APP 启动时,对需要解析的域名做预先解析,然后把解析的结果缓存到本地的一个 LRU 缓存里面。这样当我们要使用这个域名的时候,只需要从缓存中直接拿到所需要的 IP 地址就好了,如果缓存中不存在才会走整个 DNS 查询的过程。 同时, 为了避免 DNS 解析结果的变更造成缓存内数据失效,我们可以启动一个定时器,定期地更新缓存中的数据。

我曾经测试过这种方式, 对于 HTTP 请求的响应时间的提升是很明显的,原先 DNS 解析时间经常会超过 1s,使用这种方式后,DNS 解析时间可以控制在 200ms 之内,整个 HTTP 请求的过程也可以减少大概 80ms~100ms。

NAME

这里总结一下, 将用户的请求映射到 CDN 服务器上,是使用 CDN 时需要解决的一个核心的问题,而 CNAME 记录在 DNS 解析过程中可以充当一个中间代理层的角色,可以把将用户最初使用的域名代理到正确的 IP 地址上。图片:

NAME

现在,剩下的一个问题就是如何找到更近的 CDN 节点了,而 GSLB 承担了这个职责。

2. 如何找到离用户最近的 CDN 节点

GSLB(Global Server Load Balance,全局负载均衡),它的含义是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。 它有两方面的作用:

  • 一方面,它是一种负载均衡服务器,负载均衡,顾名思义嘛,指的是让流量平均分配使得下面管理的服务器的负载更平均;
  • 另一方面,它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。

GSLB 可以 通过多种策略,来保证返回的 CDN 节点和用户尽量保证在同一地缘区域,比如说可以将用户的 IP 地址按照地理位置划分为若干的区域,然后将 CDN 节点对应到一个区域上,然后根据用户所在区域来返回合适的节点;也可以通过发送数据包测量 RTT 的方式来决定返回哪一个节点。 不过,这些原理不是本节课重点内容, 你了解一下就可以了,我不做详细的介绍。

有了 GSLB 之后,节点的解析过程变成了下图中的样子:

NAME

当然,是否能够从 CDN 节点上获取到资源还取决于 CDN 的同步延时。 一般,我们会通过 CDN 厂商的接口将静态的资源写入到某一个 CDN 节点上 ,再由 CDN 内部的同步机制将资源分散同步到每个 CDN 节点,即使 CDN 内部网络经过了优化,这个同步的过程是有延时的,一旦我们无法从选定的 CDN 节点上获取到数据,我们就不得不从源站获取数据,而用户网络到源站的网络可能会跨越多个主干网,这样不仅性能上有损耗,也会消耗源站的带宽,带来更高的研发成本。所以,我们在使用 CDN 的时候需要关注 CDN 的命中率和源站的带宽情况。

课程小结

本节课,我主要带你了解了 CDN 对静态资源进行加速的原理和使用的核心技术,这里你需要了解的重点有以下几点:

  1. DNS 技术是 CDN 实现中使用的核心技术,可以将用户的请求映射到 CDN 节点上;
  2. DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间;
  3. GSLB 可以给用户返回一个离着他更近的节点,加快静态资源的访问速度。

作为一个服务端开发人员,你可能会忽略 CDN 的重要性,对于偶尔出现的 CDN 问题嗤之以鼻,觉得这个不是我们应该关心的内容, 这种想法是错的。

CDN 是我们系统的门面,其缓存的静态数据,如图片和视频数据的请求量很可能是接口请求数据的几倍甚至更高,一旦发生故障,对于整体系统的影响是巨大的。另外 CDN 的带宽历来是我们研发成本的大头, 尤其是目前处于小视频和直播风口上, 大量的小视频和直播研发团队都在绞尽脑汁地减少 CDN 的成本。由此看出,CDN 是我们整体系统至关重要的组成部分,而它作为一种特殊的缓存,其命中率和可用性也是我们服务端开发人员需要重点关注的指标。

16 - CH17-缓存-数据迁移

在 「数据库优化方案(二):写入数据量增加时,如何实现分库分表?」中我曾经提到,由于 MySQL 不像 MongoDB 那样支持数据的 Auto Sharding(自动分片),所以无论是将 MySQL 单库拆分成多个数据库,还是由于数据存储的瓶颈,不得不将多个数据库拆分成更多的数据库时,你都要考虑如何做数据的迁移。

其实,在实际工作中,不只是对数据库拆分时会做数据迁移, 很多场景都需要你给出数据迁移的方案, 比如说某一天,你的老板想要将应用从自建机房迁移到云上,那么你就要考虑将所有自建机房中的数据,包括 MySQL,Redis,消息队列等组件中的数据,全部迁移到云上,这无论对哪种规模的公司来说都是一项浩瀚的工程,所以你需要在迁移之前,准备完善的迁移方案。

数据的迁移的问题比较重要,也比较繁琐,也是开发和运维同学关注的重点。

如何平滑地迁移数据库中的数据

你可能会认为:数据迁移无非是将数据从一个数据库拷贝到另一个数据库,可以通过 MySQL 主从同步的方式做到准实时的数据拷贝;也可以通过 mysqldump 工具将源库的数据导出,再导入到新库,这有什么复杂的呢?

其实,这两种方式只能支持单库到单库的迁移,无法支持单库到多库多表的场景。而且即便是单库到单库的迁移,迁移过程也需要满足以下几个目标:

  • 迁移应该是在线的迁移,也就是在迁移的同时还会有数据的写入;
  • 数据应该保证完整性,也就是说在迁移之后需要保证新的库和旧的库的数据是一致的;
  • 迁移的过程需要做到可以回滚,这样一旦迁移的过程中出现问题,可以立刻回滚到源库,不会对系统的可用性造成影响。

如果你使用 Binlog 同步的方式,在同步完成后再修改代码,将主库修改为新的数据库,这样就不满足可回滚的要求,一旦迁移后发现问题,由于已经有增量的数据写入了新库而没有写入旧库,不可能再将数据库改成旧库。

一般来说,我们有两种方案可以做数据库的迁移。

双写方案

第一种方案我称之为双写,其实说起来也很简单,它可以分为以下几个步骤:

  1. 将新的库配置为源库的从库,用来同步数据;

    如果需要将数据同步到多库多表,那么可以使用一些第三方工具获取 Binlog 的增量日志(比如开源工具 Canal),在获取增量日志之后就可以按照分库分表的逻辑写入到新的库表中了。

  2. 同时,我们需要改造业务代码,在数据写入的时候,不仅要写入旧库,也要写入新库。

    当然,基于性能的考虑,我们可以异步地写入新库,只要保证旧库写入成功即可。 但是,我们需要注意的是, 需要将写入新库失败的数据记录在单独的日志中,这样方便后续对这些数据补写,保证新库和旧库的数据一致性。

  3. 然后,我们就可以开始校验数据了。由于数据库中数据量很大,做全量的数据校验不太现实。你可以抽取部分数据,具体数据量依据总体数据量而定,只要保证这些数据是一致的就可以。

  4. 如果一切顺利,我们就可以将读流量切换到新库了。

    由于担心一次切换全量读流量可能会对系统产生未知的影响,所以这里 最好采用灰度的方式来切换, 比如开始切换 10% 的流量,如果没有问题再切换到 50% 的流量,最后再切换到 100%。

  5. 由于有双写的存在,所以在切换的过程中出现任何的问题,都可以将读写流量随时切换到旧库去,保障系统的性能。

  6. 在观察了几天发现数据的迁移没有问题之后,就可以将数据库的双写改造成只写新库,数据的迁移也就完成了。

NAME

其中,最容易出问题的步骤就是数据校验的工作, 所以,我建议你在未开始迁移数据之前先写好数据校验的工具或者脚本,在测试环境上测试充分之后,再开始正式的数据迁移。

如果是将数据从自建机房迁移到云上,你也可以使用这个方案, 只是你需要考虑的一个重要的因素是: 自建机房到云上的专线的带宽和延迟,你需要尽量减少跨专线的读操作,所以在切换读流量的时候,你需要保证自建机房的应用服务器读取本机房的数据库,云上的应用服务器读取云上的数据库。这样在完成迁移之前,只要将自建机房的应用服务器停掉,并且将写入流量都切到新库就可以了。

NAME

这种方案是一种比较通用的方案,无论是迁移 MySQL 中的数据,还是迁移 Redis 中的数据,甚至迁移消息队列都可以使用这种方式, 你在实际的工作中可以直接拿来使用。

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

级联同步方案

这种方案也比较简单,比较适合数据从自建机房向云上迁移的场景。因为迁移上云,最担心云上的环境和自建机房的环境不一致,会导致数据库在云上运行时,因为参数配置或者硬件环境不同出现问题。

所以,我们会在自建机房准备一个备库,在云上环境上准备一个新库,通过级联同步的方式在自建机房留下一个可回滚的数据库,具体的步骤如下:

  1. 先将新库配置为旧库的从库,用作数据同步;
  2. 再将一个备库配置为新库的从库,用作数据的备份;
  3. 等到三个库的写入一致后,将数据库的读流量切换到新库;
  4. 然后暂停应用的写入,将业务的写入流量切换到新库(由于这里需要暂停应用的写入,所以需要安排在业务的低峰期)。
NAME

这种方案的回滚方案也比较简单, 可以先将读流量切换到备库,再暂停应用的写入,将写流量切换到备库,这样所有的流量都切换到了备库,也就是又回到了自建机房的环境,就可以认为已经回滚了。

NAME

上面的级联迁移方案可以应用在,将 MySQL 从自建机房迁移到云上的场景,也可以应用在将 Redis 从自建机房迁移到云上的场景, 如果你有类似的需求可以直接拿来应用。

这种方案 优势是 简单易实施,在业务上基本没有改造的成本;

缺点是 在切写的时候需要短暂的停止写入,对于业务来说是有损的,不过如果在业务低峰期来执行切写,可以将对业务的影响降至最低。

数据迁移时如何预热缓存

另外,在从自建机房向云上迁移数据时,我们也需要考虑缓存的迁移方案是怎样的。那么你可能会说:缓存本来就是作为一个中间的存储而存在的,我只需要在云上部署一个空的缓存节点,云上的请求也会穿透到云上的数据库,然后回种缓存,对于业务是没有影响的。

你说的没错,但是你还需要考虑的是缓存的命中率。

如果你部署一个空的缓存,那么所有的请求就都穿透到数据库,数据库可能因为承受不了这么大的压力而宕机,这样你的服务就会不可用了。 所以,缓存迁移的重点是保持缓存的热度。

刚刚我提到,Redis 的数据迁移可以使用双写的方案或者级联同步的方案,所以在这里我就不考虑 Redis 缓存的同步了,而是以 Memcached 为例来说明。

使用副本组预热缓存

在「缓存的使用姿势(二):缓存如何做到高可用?」中,我曾经提到,为了保证缓存的可用性,我们可以部署多个副本组来尽量将请求阻挡在数据库层之上。

数据的写入流程是写入 Master、Slave 和所有的副本组,而在读取数据的时候,会先读副本组的数据,如果读取不到再到 Master 和 Slave 里面加载数据,再写入到副本组中。 那么,我们就可以在云上部署一个副本组, 这样,云上的应用服务器读取云上的副本组,如果副本组没有查询到数据,就可以从自建机房部署的主从缓存上加载数据,回种到云上的副本组上。

NAME

当云上部署的副本组足够热之后,也就是缓存的命中率达到至少 90%,就可以将云机房上的缓存服务器的主从都指向这个副本组,这时迁移也就完成了。

这种方式足够简单,不过有一个致命的问题是: 如果云上的请求穿透云上的副本组,到达自建机房的主从缓存时,这个过程是需要跨越专线的。

这不仅会占用较多专线的带宽,同时专线的延迟相比于缓存的读取时间是比较大的,一般,即使是本地的不同机房之间的延迟也会达到 2ms~3ms,那么,一次前端请求可能会访问十几次甚至几十次的缓存,一次请求就会平白增加几十毫秒甚至过百毫秒的延迟,会极大地影响接口的响应时间,因此在实际项目中我们很少使用这种方案。

但是,这种方案给了我们思路, 让我们可以通过方案的设计在系统运行中自动完成缓存的预热,所以,我们对副本组的方案做了一些改造,以尽量减少对专线带宽的占用。

改造副本组方案预热缓存

改造后的方案对读写缓存的方式进行改造,步骤是这样的:

  1. 在云上部署多组 mc 的副本组,自建机房在接收到写入请求时,会优先写入自建机房的缓存节点,异步写入云上部署的 mc 节点;
  2. 在处理自建机房的读请求时,会指定一定的流量,比如 10%,优先走云上的缓存节点,这样虽然也会走专线穿透回自建机房的缓存节点,但是流量是可控的;
  3. 当云上缓存节点的命中率达到 90% 以上时,就可以在云上部署应用服务器,让云上的应用服务器完全走云上的缓存节点就可以了。
NAME

使用了这种方式,我们可以实现缓存数据的迁移,又可以尽量控制专线的带宽和请求的延迟情况, 你也可以直接在项目中使用。

课程小结

以上我提到的数据迁移的方案,都是我在实际项目中,经常用到的、经受过实战考验的方案,希望你能通过这节课的学习,将这些方案运用到你的项目中,解决实际的问题。与此同时,我想再次跟你强调一下本节课的重点内容:

双写的方案是数据库、Redis 迁移的通用方案, 你可以在实际工作中直接加以使用。 双写方案中最重要的,是通过数据校验来保证数据的一致性,这样就可以在迁移过程中随时回滚;

如果你需要将自建机房的数据迁移到云上,那么也可以考虑 使用级联复制的方案, 这种方案会造成数据的短暂停写,需要在业务低峰期执行;

缓存的迁移重点,是保证云上缓存的命中率,你可以 使用改进版的副本组方式来迁移, 在缓存写入的时候,异步写入云上的副本组,在读取时放少量流量到云上副本组,从而又可以迁移部分数据到云上副本组,又能尽量减少穿透给自建机房造成专线延迟的问题。

如果你作为项目的负责人, 那么在迁移的过程中,你一定要制定周密的计划:如果是数据库的迁移,那么数据的校验应该是你最需要花费时间来解决的问题。

如果是自建机房迁移到云上,那么专线的带宽一定是你迁移过程中的一个瓶颈点,你需要在迁移之前梳理清楚,有哪些调用需要经过专线,占用带宽的情况是怎样的,带宽的延时是否能够满足要求。你的方案中也需要尽量做到在迁移过程中,同机房的服务,调用同机房的缓存和数据库,尽量减少对于专线带宽资源的占用。

17 - CH18-消息-基础

在课程一开始,我就带你了解了高并发系统设计的三个目标:性能、可用性和可扩展性,而在提升系统性能方面,我们一直关注的是系统的查询性能。也用了很多的篇幅去讲解数据库的分布式改造,各类缓存的原理和使用技巧。 究其原因在于, 我们遇到的大部分场景都是读多写少, 尤其是在一个系统的初级阶段。

比如说,一个社区的系统初期一定是只有少量的种子用户在生产内容,而大部分的用户都在「围观」别人在说什么。此时,整体的流量比较小,而写流量可能只占整体流量的百分之一,那么即使整体的 QPS 到了 10000 次 / 秒,写请求也只是到了每秒 100 次,如果要对写请求做性能优化,它的性价比确实不太高。

但是,随着业务的发展,你可能会遇到一些存在 高并发写请求的场景,其中秒杀抢购就是最典型的场景。 假设你的商城策划了一期秒杀活动,活动在第五天的 00:00 开始,仅限前 200 名,那么秒杀即将开始时,后台会显示用户正在疯狂地刷新 APP 或者浏览器来保证自己能够尽量早的看到商品。

这时,你面对的依旧是读请求过高, 那么应对的措施有哪些呢?

因为用户查询的是少量的商品数据,属于 查询的热点数据 ,你可以采用缓存策略,将请求尽量挡在上层的缓存中,能被静态化的数据,比如说商城里的图片和视频数据,尽量做到静态化,这样就可以命中 CDN 节点缓存 ,减少 Web 服务器的查询量和带宽负担。Web 服务器比如 Nginx 可以直接访问分布式缓存节点,这样可以避免请求到达 Tomcat 等业务服务器。

当然,你可以加上一些限流的策略,比如,对于短时间之内来自某一个用户、某一个 IP 或者某一台设备的重复请求做丢弃处理。

通过这几种方式,你发现自己可以将请求尽量挡在数据库之外了。

稍微缓解了读请求之后,00:00 分秒杀活动准时开始,用户瞬间向电商系统请求生成订单,扣减库存,用户的这些写操作都是不经过缓存直达数据库的。1 秒钟之内,有 1 万个数据库连接同时达到,系统的数据库濒临崩溃,寻找能够应对如此高并发的写请求方案迫在眉睫。这时你想到了消息队列。

我所理解的消息队列

关于消息队列是什么,你可能有所了解了,所以有关它的概念讲解,就不是本节课的重点,这里只聊聊我自己对消息队列的看法。在我历年的工作经历中,我一直把消息队列看作暂时存储数据的一个容器,认为消息队列是一个平衡低速系统和高速系统处理任务时间差的工具, 我给你举个形象的例子。

比方说,古代的臣子经常去朝见皇上陈述一些国家大事,等着皇上拍板做决策。但是大臣很多,如果同时去找皇上,你说一句我说一句,皇上肯定会崩溃。后来变成臣子到了午门之后要原地等着皇上将他们一个一个地召见进大殿商议国事,这样就可以缓解皇上处理事情的压力了。你可以把午门看作一个暂时容纳臣子的容器,也就是我们所说的消息队列。

其实,你在一些组件中都会看到消息队列的影子:

  • 在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理这些任务;
  • 操作系统中,中断的下半部分也会使用工作队列来实现延后执行;
  • 我们在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个工作线程来处理。
  • ……

总之,队列是在系统设计时一种常见的组件。

那么我们如何用消息队列解决秒杀场景下的问题呢?接下来,我们来结合具体的例子来看看消息队列在秒杀场景下起到的作用。

削去秒杀场景下的峰值写流量

刚才提到,在秒杀场景下,短时间之内数据库的写流量会很高,那么依照我们以前的思路应该对数据做分库分表。如果已经做了分库分表,那么就需要扩展更多的数据库来应对更高的写流量。但是无论是分库分表,还是扩充更多的数据库,都会比较复杂,原因是你需要将数据库中的数据做迁移,这个时间就要按天甚至按周来计算了。

而在秒杀场景下,高并发的写请求并不是持续的,也不是经常发生的,而只有在秒杀活动开始后的几秒或者十几秒时间内才会存在。为了应对这十几秒的瞬间写高峰 ,就要花费几天甚至几周的时间来扩容数据库,再在秒杀之后花费几天的时间来做缩容, 这无疑是得不偿失的。

所以,我们的思路是: 将秒杀请求暂存在消息队列中,然后业务服务器会响应用户 「秒杀结果正在计算中」,释放了系统资源之后再处理其它用户的请求。

我们会在后台启动若干个队列处理程序,消费消息队列中的消息,再执行校验库存、下单等逻辑。因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的 。而请求是可以在消息队列中被短暂地堆积, 当库存被消耗完之后,消息队列中堆积的请求就可以被丢弃了

NAME

这就是消息队列在秒杀系统中最主要的作用: 削峰填谷, 也就是说它可以削平短暂的流量高峰,虽说堆积会造成请求被短暂延迟处理,但是只要我们时刻监控消息队列中的堆积长度,在堆积量超过一定量时,增加队列处理机数量,来提升消息的处理能力就好了,而且秒杀的用户对于短暂延迟知晓秒杀的结果,也是有一定容忍度的。

这里需要注意一下, 我所说的是 「 短暂 」延迟,如果长时间没有给用户公示秒杀结果,那么用户可能就会怀疑你的秒杀活动有猫腻了。所以,在使用消息队列应对流量峰值时,需要对队列处理的时间、前端写入流量的大小,数据库处理能力做好评估,然后根据不同的量级来决定部署多少台队列处理程序。

比如你的秒杀商品有 1000 件,处理一次购买请求的时间是 500ms,那么总共就需要 500s 的时间。这时,你部署 10 个队列处理程序,那么秒杀请求的处理时间就是 50s,也就是说用户需要等待 50s 才可以看到秒杀的结果,这是可以接受的。这时会并发 10 个请求到达数据库,并不会对数据库造成很大的压力。

通过异步处理简化秒杀请求中的业务流程

其实,在大量的写请求 「攻击」你的电商系统的时候,消息队列除了发挥主要的削峰填谷的作用之外,还可以实现 异步处理 来简化秒杀请求中的业务流程,提升系统的性能。

你想,在刚才提到的秒杀场景下,我们在处理购买请求时,需要 500ms。这时,你分析了一下整个的购买流程,发现 这里面会有主要的业务逻辑,也会有次要的业务逻辑: 比如说,主要的流程是生成订单、扣减库存;次要的流程可能是我们在下单购买成功之后会给用户发放优惠券,会增加用户的积分。

假如发放优惠券的耗时是 50ms,增加用户积分的耗时也是 50ms,那么如果我们将发放优惠券、增加积分的操作放在另外一个队列处理机中执行,那么整个流程就缩短到了 400ms,性能提升了 20%,处理这 1000 件商品的时间就变成了 400s。如果我们还是希望能在 50s 之内看到秒杀结果的话,只需要部署 8 个队列程序就好了。

经过将一些业务流程异步处理之后,我们的秒杀系统部署结构也会有所改变:

NAME

解耦实现秒杀系统模块之间松耦合

除了异步处理和削峰填谷以外,消息队列在秒杀系统中起到的另一个作用是 解耦合

比如数据团队对你说,在秒杀活动之后想要统计活动的数据,借此来分析活动商品的受欢迎程度、购买者人群的特点以及用户对于秒杀互动的满意程度等等指标。 而我们需要将大量的数据发送给数据团队 ,那么要怎么做呢?

一个思路是: 可以使用 HTTP 或者 RPC 的方式来同步地调用,也就是数据团队这边提供一个接口,我们实时将秒杀的数据推送给它, 但是这样调用会有两个问题:

  • 整体系统的耦合性比较强,当数据团队的接口发生故障时,会影响到秒杀系统的可用性。
  • 当数据系统需要新的字段,就要变更接口的参数,那么秒杀系统也要随着一起变更。

这时,我们可以考虑使用消息队列降低业务系统和数据系统的直接耦合度。

秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后数据团队再订阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。

秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时,当数据系统需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。

NAME

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

  • 异步处理可以简化业务流程中的步骤,提升系统性能;
  • 削峰填谷可以削去到达秒杀系统的峰值流量,让业务逻辑的处理更加缓和;
  • 解耦合可以将秒杀系统和数据系统解耦开,这样两个系统的任何变更都不会影响到另一个系统,

如果你的系统想要提升写入性能,实现系统的低耦合,想要抵挡高并发的写流量,那么你就可以考虑使用消息队列来完成。

课程小结

本节课,我结合自己的实际经验,主要带你了解了,消息队列在高并发系统设计中起到的作用,以及一些注意事项,你需要了解的重点如下:

  • 削峰填谷是消息队列最主要的作用, 但是会造成请求处理的延迟
  • 异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消息存在着丢失的风险,我们需要考虑如何确保消息一定到达。
  • 解耦合可以提升你的整体系统的鲁棒性。

当然,你要知道,在使用消息队列之后虽然可以解决现有的问题,但是系统的复杂度也会上升。比如上面提到的业务流程中,同步流程和异步流程的边界在哪里?消息是否会丢失,是否会重复?请求的延迟如何能够减少?消息接收的顺序是否会影响到业务流程的正常执行?如果消息处理流程失败了之后是否需要补发? 这些问题都是我们需要考虑的。

我会利用接下来的两节课,针对最主要的两个问题来讲讲解决思路:一个是如何处理消息的丢失和重复,另一个是如何减少消息的延迟。

引入了消息队列的同时也会引入了新的问题,需要新的方案来解决,这就是系统设计的挑战,也是系统设计独有的魅力,而我们也会在这些挑战中不断提升技术能力和系统设计能力。

18 - CH19-消息-投递

经过上一节课,我们在电商系统中增加了消息队列,用它来对峰值写流量做削峰填谷,对次要的业务逻辑做异步处理,对不同的系统模块做解耦合。因为业务逻辑从同步代码中移除了,所以,我们也要有相应的队列处理程序来处理消息、执行业务逻辑, 这时,你的系统架构变成了下面的样子:

NAME

这是一个简化版的架构图,实际上,随着业务逻辑越来越复杂,会引入更多的外部系统和服务来解决业务上的问题。比如说,我们会引入 Elasticsearch 来解决商品和店铺搜索的问题,也会引入审核系统,来对售卖的商品、用户的评论做自动的和人工的审核,你会越来越多地使用消息队列与外部系统解耦合,以及提升系统性能。

比如说,你的电商系统需要上一个新的红包功能:用户在购买一定数量的商品之后,由你的系统给用户发一个现金的红包,鼓励用户消费。由于发放红包的过程不应该在购买商品的主流程之内,所以你考虑使用消息队列来异步处理。 这时,你发现了一个问题: 如果消息在投递的过程中发生丢失,那么用户就会因为没有得到红包而投诉。相反,如果消息在投递的过程中出现了重复,那么你的系统就会因为发送两个红包而损失。

那么我们如何保证,产生的消息一定会被消费到,并且只被消费一次呢 ?这个问题虽然听起来很浅显,很好理解,但是实际上却藏着很多玄机,本节课我就带你深入探讨。

消息为什么会丢失

如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列,到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实,主要存在三个场景:

  • 消息从生产者写入到消息队列的过程。
  • 消息在消息队列中的存储场景。
  • 消息被消费者消费的过程。
NAME

接下来,我就针对每一个场景,详细地剖析一下,这样你可以针对不同的场景选择合适的,减少消息丢失的解决方案。

1. 在消息生产的过程中丢失消息

在这个环节中主要有两种情况。

首先,消息的生产者一般是我们的业务服务器,消息队列是独立部署在单独的服务器上的。两者之间的网络虽然是内网,但是也会存在抖动的可能,而一旦发生抖动,消息就有可能因为网络的错误而丢失。

针对这种情况,我建议你采用的方案是消息重传: 也就是当你发现发送超时后你就将消息重新发一次,但是你也不能无限制地重传消息。一般来说,如果不是消息队列发生故障,或者是到消息队列的网络断开了,重试 2~3 次就可以了。

不过,这种方案可能会造成消息的重复,从而导致在消费的时候会重复消费同样的消息。比方说,消息生产时由于消息队列处理慢或者网络的抖动,导致虽然最终写入消息队列成功,但在生产端却超时了,生产者重传这条消息就会形成重复的消息,那么针对上面的例子,直观显示在你面前的就会是你收到了两个现金红包。

那么消息发送到了消息队列之后是否就万无一失了呢?当然不是, 在消息队列中消息仍然有丢失的风险。

2. 在消息队列中丢失消息

拿 Kafka 举例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中,然后再找合适的时机刷新到磁盘上。

比如,Kafka 可以配置当达到某一时间间隔,或者累积一定的消息数量的时候再刷盘, 也就是所说的异步刷盘。

来看一个形象的比喻:假如你经营一个图书馆,读者每还一本书你都要去把图书归位,不仅工作量大而且效率低下,但是如果你可以选择每隔 3 小时,或者图书达到一定数量的时候再把图书归位,这样可以把同一类型的书一起归位,节省了查找图书位置的时间,这样就可以提高效率了。

不过,如果发生机器掉电或者机器异常重启,那么 Page Cache 中还没有来得及刷盘的消息就会丢失了。 那么怎么解决呢?

你可能会把刷盘的间隔设置很短,或者设置累积一条消息就就刷盘,但这样频繁刷盘会对性能有比较大的影响,而且从经验来看,出现机器宕机或者掉电的几率也不高, 所以我不建议你这样做。

NAME

如果你的电商系统对消息丢失的容忍度很低, 那么你可以考虑以集群方式部署 Kafka 服务,通过部署多个副本备份数据,保证消息尽量不丢失。

那么它是怎么实现的呢?

Kafka 集群中有一个 Leader 负责消息的写入和消费,可以有多个 Follower 负责数据的备份。Follower 中有一个特殊的集合叫做 ISR(in-sync replicas),当 Leader 故障时,新选举出来的 Leader 会从 ISR 中选择,默认 Leader 的数据会异步地复制给 Follower,这样在 Leader 发生掉电或者宕机时,Kafka 会从 Follower 中消费消息,减少消息丢失的可能。

由于默认消息是异步地从 Leader 复制到 Follower 的,所以一旦 Leader 宕机,那些还没有来得及复制到 Follower 的消息还是会丢失。为了解决这个问题,Kafka 为生产者提供一个选项叫做 acks,当这个选项被设置为 all 时,生产者发送的每一条消息除了发给 Leader 外还会发给所有的 ISR,并且必须得到 Leader 和所有 ISR 的确认后才被认为发送成功。这样,只有 Leader 和所有的 ISR 都挂了,消息才会丢失。

NAME

从上面这张图来看,当设置 acks=all 时,需要同步执行 1,3,4 三个步骤,对于消息生产的性能来说也是有比较大的影响的,所以你在实际应用中需要仔细地权衡考量。 我给你的建议是:

  1. 如果你需要确保消息一条都不能丢失,那么建议不要开启消息队列的同步刷盘,而是需要使用集群的方式来解决,可以配置当所有 ISR Follower 都接收到消息才返回成功。
  2. 如果对消息的丢失有一定的容忍度,那么建议不部署集群,即使以集群方式部署,也建议配置只发送给一个 Follower 就可以返回成功了。
  3. 我们的业务系统一般对于消息的丢失有一定的容忍度,比如说以上面的红包系统为例,如果红包消息丢失了,我们只要后续给没有发送红包的用户补发红包就好了。

3. 在消费的过程中存在消息丢失的可能

我还是以 Kafka 为例来说明。一个消费者消费消息的进度是记录在消息队列集群中的,而消费的过程分为三步:接收消息、处理消息、更新消费进度。

这里面接收消息和处理消息的过程都可能会发生异常或者失败,比如说,消息接收时网络发生抖动,导致消息并没有被正确的接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,那么这条失败的消息就永远不会被处理了,也可以认为是丢失了。

所以,在这里你需要注意的是, 一定要等到消息接收和处理完成后才能更新消费进度,但是这也会造成消息重复的问题,比方说某一条消息在处理之后,消费者恰好宕机了,那么因为没有更新消费进度,所以当这个消费者重启之后,还会重复地消费这条消息。

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

从上面的分析中,你能发现,为了避免消息丢失,我们需要付出两方面的代价:一方面是性能的损耗;一方面可能造成消息重复消费。

性能的损耗我们还可以接受,因为一般业务系统只有在写请求时才会有发送消息队列的操作,而一般系统的写请求的量级并不高,但是消息一旦被重复消费,就会造成业务逻辑处理的错误。那么我们要如何避免消息的重复呢?

想要完全的避免消息重复的发生是很难做到的,因为网络的抖动、机器的宕机和处理的异常都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使消费到了重复的消息,从消费的最终结果来看和只消费一次是等同的就好了,也就是保证在消息的生产和消费的过程是 幂等 的。

1. 什么是幂等

幂等是一个数学上的概念,它的含义是多次执行同一个操作和执行一次操作,最终得到的结果是相同的,说起来可能有些抽象,我给你举个例子:

比如,男生和女生吵架,女生抓住一个点不放,传递 你不在乎我了吗?(生产消息)的信息。那么当多次埋怨 你不在乎我了吗? 的时候(多次生产相同消息),她不知道的是,男生的耳朵(消息处理)会自动把 N 多次的信息屏蔽,就像只听到一次一样,这就是幂等性。

如果我们消费一条消息的时候,要给现有的库存数量减 1,那么如果消费两条相同的消息就会给库存数量减 2,这就不是幂等的。而如果消费一条消息后,处理逻辑是将库存的数量设置为 0,或者是如果当前库存数量是 10 时则减 1,这样在消费多条消息时,所得到的结果就是相同的, 这就是幂等的。

说白了,你可以这么理解幂等: 一件事儿无论做多少次都和做一次产生的结果是一样的,那么这件事儿就具有幂等性。

2. 在生产、消费过程中增加消息幂等性的保证

消息在生产和消费的过程中都可能会产生重复,所以你要做的是,在生产过程和消费过程中增加消息幂等性的保证,这样就可以认为从 最终结果上来看,消息实际上是只被消费了一次的。

在消息生产过程中, 在 Kafka0.11 版本和 Pulsar 中都支持 producer idempotency 的特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但是最终在消息队列存储时只会存储一份

它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致,就认为是重复的消息,服务端会自动丢弃。

笔者疑问:这里说得很模糊,光看含义,并不知道 kafka 怎么能给一个提交消息给一个 ID,我重复提交,还能识别重复了?和能保证唯一

NAME

而在消费端 , 幂等性的保证会稍微复杂一些,你可以从 通用层业务层 两个层面来考虑。

在通用层面,你可以在消息被生产的时候,使用发号器给它生成一个全局唯一的消息 ID,消息被处理之后,把这个 ID 存储在数据库中,在处理下一条消息之前,先从数据库里面查询这个全局 ID 是否被消费过,如果被消费过就放弃消费。

你可以看到,无论是生产端的幂等性保证方式,还是消费端通用的幂等性保证方式,它们的共同特点都是为每一个消息生成一个唯一的 ID,然后在使用这个消息的时候,先比对这个 ID 是否已经存在,如果存在,则认为消息已经被使用过。所以这种方式是一种标准的实现幂等的方式, 你在项目之中可以拿来直接使用, 它在逻辑上的伪代码就像下面这样:


boolean isIDExisted = selectByID(ID); // 判断 ID 是否存在
if(isIDExisted) {
  return; // 存在则直接返回
} else {
  process(message); // 不存在,则处理消息
  saveID(ID);   // 存储 ID
}

不过这样会有一个问题: 如果消息在处理之后,还没有来得及写入数据库,消费者宕机了重启之后发现数据库中并没有这条消息,还是会重复执行两次消费逻辑,这时你就需要引入事务机制,保证消息处理和写入数据库必须同时成功或者同时失败,但是这样消息处理的成本就更高了,所以,如果对于消息重复没有特别严格的要求,可以直接使用这种通用的方案,而不考虑引入事务。

在业务层面怎么处理呢? 这里有很多种处理方式,其中有一种是增加乐观锁的方式。比如,你的消息处理程序需要给一个人的账号加钱,那么你可以通过乐观锁的方式来解决。

具体的操作方式是这样的: 你给每个人的账号数据中增加一个版本号的字段,在生产消息时先查询这个账户的版本号,并且将版本号连同消息一起发送给消息队列。消费端在拿到消息和版本号后,在执行更新账户金额 SQL 的时候带上版本号,类似于执行:

update user set amount = amount + 20, version=version+1 where userId=1 and version=1;

你看,我们在更新数据时给数据加了乐观锁,这样在消费第一条消息时,version 值为 1,SQL 可以执行成功,并且同时把 version 值改为了 2;在执行第二条相同的消息时,由于 version 值不再是 1,所以这条 SQL 不能执行成功,也就保证了消息的幂等性。

课程小结

本节课,我主要带你了解了在消息队列中,消息可能会发生丢失的场景,和我们的应对方法,以及在消息重复的场景下,你要如何保证,尽量不影响消息最终的处理结果。我想强调的重点是:

  • 消息的丢失可以通过生产端的重试、消息队列配置集群模式,以及消费端合理处理消费进度三个方式来解决。
  • 为了解决消息的丢失通常会造成性能上的问题以及消息的重复问题。
  • 通过保证消息处理的幂等性可以解决消息的重复问题。

虽然我讲了很多应对消息丢失的方法,但并不是说消息丢失一定不能被接受,毕竟你可以看到,在允许消息丢失的情况下,消息队列的性能更好,方案实现的复杂度也最低。比如像是日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发的丢失几条日志是可以接受的。

所以方案设计看场景,这是一切设计的原则, 你不能把所有的消息队列都配置成防止消息丢失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外的负担。

19 - CH20-消息-延迟

学完前面两节课之后,相信你对在垂直电商项目中,如何使用消息队列应对秒杀时的峰值流量已经有所了解。当然了,你也应该知道要如何做,才能保证消息不会丢失,尽量避免消息重复带来的影响。 那么我想让你思考一下: 除了这些内容,你在使用消息队列时还需要关注哪些点呢?

先来看一个场景: 在你的垂直电商项目中,你会在用户下单支付之后,向消息队列里面发送一条消息,队列处理程序消费了消息后,会增加用户的积分,或者给用户发送优惠券。那么用户在下单之后,等待几分钟或者十几分钟拿到积分和优惠券是可以接受的,但是一旦消息队列出现大量堆积,用户消费完成后几小时还拿到优惠券,那就会有用户投诉了。

这时,你要关注的就是消息队列中, 消息的延迟 了,这其实是消费性能的问题,那么你要如何提升消费性能,保证更短的消息延迟呢? 在我看来, 你首先需要掌握如何来监控消息的延迟 ,因为有了数据之后,你才可以知道目前的延迟数据是否满足要求,也可以评估优化之后的效果。然后,你要掌握使用消息队列的正确姿势,以及关注消息队列本身是如何保证消息尽快被存储和投递的。

接下来,我们先来看看第一点:如何监控消息延迟。

如何监控消息延迟

在我看来,监控消息的延迟有两种方式:

  • 使用消息队列提供的工具,通过监控消息的堆积来完成;
  • 通过生成监控消息的方式来监控消息的延迟情况。

接下来,我带你实际了解一下。

假设在开篇的场景之下,电商系统中的消息队列已经堆积了大量的消息,那么你要想监控消息的堆积情况,首先需要从原理上了解, 在消息队列中消费者的消费进度是多少 ,因为这样才方便计算当前的消费延迟是多少。比方说,生产者向队列中一共生产了 1000 条消息,某一个消费者消费进度是 900 条,那么这个消费者的消费延迟就是 100 条消息。

在 Kafka 中,消费者的消费进度在不同的版本上是不同的。

在 Kafka0.9 之前的版本中,消费进度是存储在 ZooKeeper 中的,消费者在消费消息的时候,先要从 ZooKeeper 中获取最新的消费进度,再从这个进度的基础上消费后面的消息。

在 Kafka0.9 版本之后,消费进度被迁入到 Kakfa 的一个专门的 topic 叫 __consumer_offsets 里面。所以,如果你了解 kafka 的原理,你可以依据不同的版本,从不同的位置,获取到这个消费进度的信息。

当然,作为一个成熟的组件,Kafka 也提供了一些工具来获取这个消费进度的信息,帮助你实现自己的监控,这个工具主要有两个

kafka-consumer-groups.sh

首先,Kafka 提供了工具叫做 kafka-consumer-groups.sh(它在 Kafka 安装包的 bin 目录下)。

为了帮助你理解,我简单地搭建了一个 Kafka 节点,并且写入和消费了一些信息,然后我来使用命令看看消息累积情况,具体的命令如下:

./bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group test-consumer-group

结果如下:

NAME
  • TOPIC、PARITION:话题名和分区名;
  • CURRENT-OFFSET:当前消费者的消费进度;
  • LOG-END-OFFSET:当前生产消息的总数;
  • LAG:消费消息的堆积数(也就是 LOG-END-OFFSET - CURRENT-OFFSET)

通过这个命令你可以很方便地了解消费者的消费情况。

JMX

Kafka 通过 JMX 暴露了消息堆积的数据,我在本地启动了一个 console consumer,然后使用 jconsole 连接这个 consumer,你就可以看到这个 consumer 的堆积数据了(就是下图中红框里的数据)。这些数据你可以写代码来获取,这样也可以方便地输出到监控系统中, 我比较推荐这种方式。

NAME

除了使用消息队列提供的工具以外,你还可以通过生成监控消息的方式,来监控消息的延迟。 **具体怎么做呢?**生成监控消息

  1. 你先定义一种特殊的消息

  2. 然后启动一个监控程序,将这个消息定时地循环写入到消息队列中

    消息的内容可以是生成消息的时间戳,并且也会作为队列的消费者消费数据。

  3. 业务处理程序消费到这个消息时直接丢弃掉,而监控程序在消费到这个消息时, 就可以和这个消息的生成时间做比较 ,如果时间差达到某一个阈值就可以向我们报警。

NAME

这两种方式都可以监控消息的消费延迟情况, 而从我的经验出来,我比较推荐两种方式结合来使用。 比如在我的实际项目中,我会优先在监控程序中获取 JMX 中的队列堆积数据,做到 dashboard 报表中,同时也会启动探测进程,确认消息的延迟情况是怎样的。

在我看来,消息的堆积是对于消息队列的基础监控,这是你无论如何都要做的。但是,了解了消息的堆积情况,并不能很直观地了解消息消费的延迟,你也只能利用经验来确定堆积的消息量到了多少才会影响到用户的体验;而第二种方式对于消费延迟的监控则更加直观,而且从时间的维度来做监控也比较容易确定报警阈值。

了解了消息延迟的监控方式之后,我们再来看看如何提升消息的写入和消费性能,这样才会让异步的消息得到尽快的处理。

减少消息延迟的正确姿势

想要减少消息的处理延迟,我们需要在 消费端和消息队列 两个层面来完成。

在消费端,我们的目标是提升消费者的消息处理能力,你能做的是:

  • 优化消费代码提升性能;
  • 增加消费者的数量(这个方式比较简单)。

不过,第二种方式会受限于消息队列的实现。比如说,如果消息队列使用的是 Kafka 就无法通过增加消费者数量的方式,来提升消息处理能力。

因为在 Kafka 中,一个 Topic(话题)可以配置多个 Partition(分区),数据会被平均或者按照生产者指定的方式,写入到多个分区中,那么在消费的时候,Kafka 约定一个分区只能被一个消费者消费,为什么要这么设计呢?在我看来,如果有多个 consumer(消费者)可以消费一个分区的数据,那么在操作这个消费进度的时候就需要加锁,可能会对性能有一定的影响。

所以说,话题的分区数量决定了消费的并行度,增加多余的消费者也是没有用处的,那么你可以通过增加分区来提高消费者的处理能力。

NAME

那么,如何在不增加分区的前提下提升消费能力呢?

既然不能增加 consumer,那么你可以在一个 consumer 中提升处理消息的并行度,所以可以考虑使用多线程的方式来增加处理能力:你可以预先创建一个或者多个线程池,在接收到消息之后,把消息丢到线程池中来异步地处理,这样,原本串行的消费消息的流程就变成了并行的消费,可以提高消息消费的吞吐量,在并行处理的前提下,我们就可以在一次和消息队列的交互中多拉取几条数据,然后分配给多个线程来处理。

NAME

另外,你在消费队列中数据的时候还需要注意消费线程空转的问题。

我是最初在测试自己写的一个消息中间件的时候发现的。 当时,我发现运行消费客户端的进程会偶发地出现 CPU 跑满的情况,于是打印了 JVM 线程堆栈,找到了那个跑满 CPU 的线程。这个时候才发现,原来是消息队列中,有一段时间没有新的消息,于是消费客户端拉取不到新的消息就会不间断地轮询拉取消息,这个线程就把 CPU 跑满了。

所以,你在写消费客户端的时候要考虑这种场景,拉取不到消息可以等待一段时间再来拉取,等待的时间不宜过长,否则会增加消息的延迟。我一般建议固定的 10ms~100ms,也可以按照一定步长递增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最长可以到 100ms,直到拉取到消息再回到 10ms。

说完了消费端的做法之后, 再来说说消息队列本身在读取性能优化方面做了哪些事情。

我曾经也做过一个消息中间件,在最初设计中间件的时候,我主要从两方面考虑读取性能问题:

  • 消息的存储;
  • 零拷贝技术。

针对第一点, 我最初在设计的时候为了实现简单,使用了普通的数据库来存储消息,但是受限于数据库的性能瓶颈,读取 QPS 只能到 2000,后面我重构了存储模块,使用本地磁盘作为存储介质。Page Cache 的存在就可以提升消息的读取速度,即使要读取磁盘中的数据,由于消息的读取是顺序的,并且不需要跨网络读取数据,所以读取消息的 QPS 提升了一个数量级。

另外一个优化点是零拷贝技术, 说是零拷贝,其实,我们不可能消灭数据的拷贝,只是尽量减少拷贝的次数。在读取消息队列的数据的时候,其实就是把磁盘中的数据通过网络发送给消费客户端,在实现上会有四次数据拷贝的步骤:

  1. 数据从磁盘拷贝到内核缓冲区;
  2. 系统调用将内核缓存区的数据拷贝到用户缓冲区;
  3. 用户缓冲区的数据被写入到 Socket 缓冲区中;
  4. 操作系统再将 Socket 缓冲区的数据拷贝到网卡的缓冲区中。
NAME

操作系统提供了 Sendfile 函数,可以减少数据被拷贝的次数。使用了 Sendfile 之后,在内核缓冲区的数据不会被拷贝到用户缓冲区,而是直接被拷贝到 Socket 缓冲区,节省了一次拷贝的过程,提升了消息发送的性能。高级语言中对于 Sendfile 函数有封装,比如说在 Java 里面的 java.nio.channels.FileChannel 类就提供了 transferTo 方法提供了 Sendfile 的功能。

NAME

课程小结

本节课我带你了解了,如何提升消息队列的性能来降低消息消费的延迟,这里我想让你明确的重点是:

  • 我们可以使用消息队列提供的工具,或者通过发送监控消息的方式,来监控消息的延迟情况;
  • 横向扩展消费者是提升消费处理能力的重要方式;
  • 选择高性能的数据存储方式,配合零拷贝技术,可以提升消息的消费性能。

其实,队列是一种常用的组件,只要涉及到队列,任务的堆积就是一个不可忽视的问题, 我遇到过的很多故障都是源于此。

比如说,前一段时间处理的一个故障,前期只是因为数据库性能衰减有少量的慢请求,结果这些慢请求占满了 Tomcat 线程池,导致整体服务的不可用。如果我们能对 Tomcat 线程池的任务堆积情况有实时地监控,或者说对线程池有一些保护策略,比方说线程全部使用之后丢弃请求,也许就会避免故障的发生。在此,我希望你在实际的工作中能够引以为戒,只要有队列就要监控它的堆积情况,把问题消灭在萌芽之中。

20 - CH21-分布式-服务拆分

通过前面几个篇章的内容,你已经从数据库、缓存和消息队列的角度对自己的垂直电商系统在性能、可用性和扩展性上做了优化。

现在,你的系统运行稳定,好评不断,每天高峰期的流量,已经达到了 10000/s 请求,DAU((Daily Active User)日活跃用户数量) 也涨到了几十万。CEO 非常高兴,打算继续完善产品功能,以便进行新一轮的运营推广,争取在下个双十一可以将 DAU 冲击过百万。这时,你开始考虑,怎么通过技术上的优化改造,来支撑更高的并发流量,比如支撑过百万的 DAU。

于是,你重新审视了自己的系统架构,分析系统中有哪些可以优化的点。

NAME

目前来看,工程的部署方式还是采用一体化架构,也就是说所有的功能模块,比方说电商系统中的订单模块、用户模块、支付模块、物流模块等等,都被打包到一个大的 Web 工程中,然后部署在应用服务器上。

你隐约觉得这样的部署方式可能存在问题,于是,你 Google 了一下,发现当系统发展到一定阶段,都要做微服务化的拆分,你也看到淘宝的「五彩石」项目,对于淘宝整体架构的扩展性,带来的巨大影响。这一切让你心驰神往。

但是有一个问题一直萦绕在你的心里:究竟是什么促使我们将一体化架构,拆分成微服务化架构?是不是说系统的整体 QPS 到了 1 万,或者到了 2 万,就一定要做微服务化拆分呢?

一体化架构的痛点

先来回想一下,你当初为什么选用了一体化架构。

在电商项目刚刚启动的时候,你只是希望能够尽量快地将项目搭建起来,方便将产品更早地投放市场,快速完成验证。

在系统开发的初期,这种架构确实给你的开发运维,带来了很大的便捷,主要体现在:

  • 开发简单直接,代码和项目集中式管理;
  • 只需要维护一个工程,节省维护系统运行的人力成本;
  • 排查问题的时候,只需要排查这个应用进程就可以了,目标性强。

但随着功能越来越复杂,开发团队规模越来越大,你慢慢感受到了一体化架构的一些缺陷,这主要体现在以下几个方面。

首先, 在技术层面上,数据库连接数可能成为系统的瓶颈。

在第 7 讲中我提到,数据库的连接是比较重的一类资源,不仅连接过程比较耗时,而且连接 MySQL 的客户端数量有限制, 最多可以设置为 16384 (在实际的项目中,可以依据实际业务来调整)。

这个数字看着很大,但是因为你的系统是按照一体化架构部署的,在部署结构上没有分层,应用服务器直接连接数据库,那么当前端请求量增加,部署的应用服务器扩容,数据库的连接数也会大增,给你举个例子。

我之前维护的一个系统中, 数据库的最大连接数设置为 8000,应用服务器部署在虚拟机上,数量大概是 50 个,每个服务器会和数据库建立 30 个连接,但是数据库的连接数,却远远大于 30 * 50 = 1500。

因为你不仅要支撑来自客户端的外网流量,还要部署单独的应用服务,支撑来自其它部门的内网调用,也要部署队列处理机,处理来自消息队列的消息,这些服务也都是与数据库直接连接的,林林总总加起来,在高峰期的时候,数据库的连接数要接近 3400。

所以,一旦遇到一些大的运营推广活动,服务器就要扩容,数据库连接数也随之增加,基本上就会处在最大连接数的边缘。这就像一颗定时炸弹,随时都会影响服务的稳定。

第二点, 一体化架构增加了研发的成本,抑制了研发效率的提升。

《人月神话》中曾经提到:一个团队内部沟通成本,和人员数量 n 有关,约等于 n(n-1)/2,也就是说随着团队人员的增加,沟通的成本呈指数级增长,一个 100 人的团队,需要沟通的渠道大概是 100(100-1)/2 = 4950。那么为了减少沟通成本,我们一般会把团队拆分成若干个小团队,每个小团队 5~7 人,负责一部分功能模块的开发和维护。

比方说,你的垂直电商系统团队就会被拆分为用户组、订单组、支付组、商品组等等。当如此多的小团队共同维护一套代码,和一个系统时,在配合时就会出现问题。

不同的团队之间沟通少,假如一个团队需要一个发送短信的功能,那么有的研发同学会认为最快的方式,不是询问其他团队是否有现成的,而是自己写一套,但是这种想法是不合适的,这样一来就会造成功能服务的重复开发。

由于代码部署在一起,每个人都向同一个代码库提交代码,代码冲突无法避免;同时,功能之间耦合严重,可能你只是更改了很小的逻辑,却导致其它功能不可用,从而在测试时需要对整体功能回归,延长了交付时间。

模块之间互相依赖,一个小团队中的成员犯了一个错误,就可能会影响到,其它团队维护的服务,对于整体系统稳定性影响很大。

第三点,一体化架构对于系统的运维也会有很大的影响。

想象一下,在项目初期,你的代码可能只有几千行,构建一次只需要一分钟,那么你可以很敏捷灵活地频繁上线变更修复问题。但是当你的系统扩充到几十万行,甚至上百万行代码的时候,一次构建的过程,包括编译、单元测试、打包和上传到正式环境,花费的时间可能达到十几分钟,并且,任何小的修改,都需要构建整个项目,上线变更的过程非常不灵活。

而我说的这些问题,都可以通过微服务化拆分来解决。

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

之前,我在做一个社区业务的时候,开始采用的架构也是一体化的架构,数据库已经做了垂直分库,分出了用户库、内容库和互动库,并且已经将工程拆分了业务池,拆分成了用户池、内容池和互动池。

当前端的请求量越来越大时,我们发现,无论哪个业务池子,用户模块都是请求量最大的模块儿,用户库也是请求量最大的数据库。这很好理解,无论是内容还是互动,都会查询用户库获取用户数据,所以,即使我们做了业务池的拆分,但实际上,每一个业务池子都需要连接用户库,并且请求量都很大,这就造成了用户库的连接数比其它都要多一些,容易成为系统的瓶颈。

NAME

那么我们怎么解决这个问题呢?

其实,可以把与用户相关的逻辑,部署成一个单独的服务,其它无论是用户池、内容池还是互动池,都连接这个服务来获取和更改用户信息,那么也就是说,只有这个服务可以连接用户库,其它的业务池都不直连用户库获取数据。

由于这个服务只处理和用户相关的逻辑,所以,不需要部署太多的实例就可以承担流量,这样就可以有效地控制用户库的连接数,提升了系统的可扩展性。那么如此一来,我们也可以将内容和互动相关的逻辑,都独立出来,形成内容服务和互动服务,这样,我们就通过 按照业务做横向拆分 的方式,解决了数据库层面的扩展性问题。

NAME

再比如,我们在做社区业务的时候,会有多个模块需要使用地理位置服务,将 IP 信息或者经纬度信息,转换为城市信息。比如,推荐内容的时候,可以结合用户的城市信息,做附近内容的推荐;展示内容信息的时候,也需要展示城市信息等等。

那么,如果每一个模块都实现这么一套逻辑就会导致代码不够重用。因此,我们可以把将 IP 信息或者经纬度信息,转换为城市信息,包装成单独的服务供其它模块调用,也就是, 我们可以将与业务无关的公用服务抽取出来,下沉成单独的服务。

按照以上两种拆分方式将系统拆分之后,每一个服务的功能内聚,维护人员职责明确,增加了新的功能只需要测试自己的服务就可以了,而一旦服务出了问题,也可以通过服务熔断、降级的方式减少对于其他服务的影响(我会在第 34 讲中系统地讲解)。

另外,由于每个服务都只是原有系统的子集,代码行数相比原有系统要小很多,构建速度上也会有比较大的提升。

当然,微服务化之后,原有单一系统被拆分成多个子服务,无论在开发,还是运维上都会引入额外的问题,那么这些问题是什么? 我们将如何解决呢?下一节课,我会带你来了解。

课程小结

本节课,我主要带你了解了,实际业务中会基于什么样的考虑,对系统做微服务化拆分,其实,系统的 QPS 并不是决定性的因素。影响的因素,我归纳为以下几点:

  • 系统中,使用的资源出现扩展性问题,尤其是数据库的连接数出现瓶颈;
  • 大团队共同维护一套代码,带来研发效率的降低,和研发成本的提升;
  • 系统部署成本越来越高。

21 - CH22-分布式-微服务

上一节课,我带你了解了,单体架构向微服务化架构演进的原因,你应该了解到,当系统依赖资源的扩展性出现问题,或者是一体化架构带来的研发成本、部署成本变得难以接受时,我们会考虑对整体系统,做微服务化拆分。

微服务化之后, 垂直电商系统的架构会将变成下面这样:

NAME

在这个架构中,我们将用户、订单和商品相关的逻辑,抽取成服务独立的部署,原本的 Web 工程和队列处理程序,将不再直接依赖缓存和数据库,而是通过调用服务接口,查询存储中的信息。

有了构思和期望之后,为了将服务化拆分尽快落地,你们决定抽调主力研发同学,共同制定拆分计划。但是细致讨论后发现,虽然对服务拆分有了大致的方向,可还是有很多疑问,比如:

  • 服务拆分时要遵循哪些原则?
  • 服务的边界如何确定?服务的粒度是怎样呢?
  • 在服务化之后,会遇到哪些问题呢?我们又将如何来解决?

当然,你也许想知道,微服务拆分的具体操作过程和步骤是怎样的,但是这部分内容涉及的知识点比较多,不太可能在一次课程中,把全部内容涵盖到。而且《DDD 实战课》中,已经侧重讲解了微服务化拆分的具体过程,你可以借鉴。

上面这三点内容,会影响服务化拆分的效果,但在实际的项目中,经常被大部分人忽略,所以是我们本节课的重点内容。而我希望你能把本节课的内容和自身的业务结合起来体会,思考业务服务化拆分的方式和方法。

微服务拆分的原则

之前,你维护的一体化架构,就像是一个大的蜘蛛网,不同功能模块,错综复杂地交织在一起,方法之间调用关系非常的复杂,导致你修复了一个 Bug,可能会引起另外多个 Bug,整体的维护成本非常高。同时,数据库较弱的扩展性,也限制了服务的扩展能力

出于上述考虑, 你要对架构做拆分。但拆分并不像听上去那么简单,这其实就是将整体工程,重构甚至重写的过程。你需要将代码,拆分到若干个子工程里面,再将这些子工程,通过一些通信方式组装起来,这对架构是很大的调整,需要跨多个团队协调完成。

所以在开始拆分之前,你需要明确几个拆分的原则,否则就会事倍功半,甚至对整体项目产生不利的影响。

原则一, 做到单一服务内部功能的高内聚,和低耦合。也就是说,每个服务只完成自己职责之内的任务,对于不是自己职责的功能,交给其它服务来完成。说起来你可能觉得理所当然,对这一点不屑一顾,但很多人在实际开发中,经常会出现一些问题。

比如,我之前的项目中, 有用户服务和内容服务,用户信息中有「是否为认证用户」字段。组内有个同学在内容服务里有这么一段逻辑:如果用户认证字段等于 1,代表是认证用户,那么就把内容权重提升。问题是,判断用户是否为认证用户的逻辑,应该内聚在用户服务内部,而不应该由内容服务判断,否则认证的逻辑一旦变更,内容服务也需要一同跟着变更,这就不满足高内聚、低耦合的要求了。所幸,我们在 Review 代码时,及时发现了这个问题,并在服务上线之前修复了它。

原则二, 你需要关注服务拆分的粒度,先粗略拆分,再逐渐细化。在服务拆分的初期,你其实很难确定,服务究竟要拆分成什么样。但是,从「微服务」这几个字来看,服务的粒度貌似应该足够小,甚至有「一方法一服务」的说法。不过,服务多了也会带来问题,像是服务个数的增加会增加运维的成本。再比如,原本一次请求只需要调用进程内的多个方法,现在则需要跨网络调用多个 RPC 服务,在性能上肯定会有所下降。

所以我推荐的做法是: 拆分初期可以把服务粒度拆的粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。 比如说, 对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。

原则三, 拆分的过程,要尽量避免影响产品的日常功能迭代,也就是说,要一边做产品功能迭代,一边完成服务化拆分。

还是拿我之前维护的一个项目举例。 我曾经在竞品对手快速发展的时期做了服务的拆分,拆分的方式是停掉所有业务开发,全盘推翻重构,结果错失了产品发展的最佳机会,最终败给了竞争对手。因此,我们的拆分只能在现有一体化系统的基础上,不断剥离业务独立部署, 剥离的顺序,你可以参考以下几点:

  1. 优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发,减少拆分对现有业务的影响,也给团队一个练习、试错的机会;

  2. 当两个服务存在依赖关系时,优先拆分被依赖的服务

    比方说,内容服务依赖于用户服务获取用户的基本信息,那么如果先把内容服务拆分出来,内容服务就会依赖于一体化架构中的用户模块,这样还是无法保证内容服务的快速部署能力。

所以正确的做法是, 你要理清服务之间的调用关系,比如说,内容服务会依赖用户服务获取用户信息,互动服务会依赖内容服务,所以要按照先用户服务,再内容服务,最后互动服务的顺序来进行拆分。

原则四, 服务接口的定义要具备可扩展性。服务拆分之后,由于服务是以独立进程的方式部署,所以服务之间通信,就不再是进程内部的方法调用,而是跨进程的网络通信了。在这种通信模型下需要注意,服务接口的定义要具备可扩展性,否则在服务变更时,会造成意想不到的错误。

在之前的项目中, 某一个微服务的接口有三个参数,在一次业务需求开发中,组内的一个同学将这个接口的参数调整为了四个,接口被调用的地方也做了修改,结果上线这个服务后,却不断报错,无奈只能回滚。

想必你明白了,这是因为这个接口先上线后,参数变更成了四个,但是调用方还未变更,还是在调用三个参数的接口,那就肯定会报错了。所以,服务接口的参数类型最好是封装类,这样如果增加参数,就不必变更接口的签名,而只需要在类中添加字段即就可以了。

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

那么,依据这些原则,将系统做微服务拆分之后,是不是就可以一劳永逸,解决所有问题了呢?当然不是。

微服务化只是一种架构手段,有效拆分后,可以帮助实现服务的敏捷开发和部署。但是,由于将原本一体化架构的应用,拆分成了,多个通过网络通信的分布式服务,为了在分布式环境下,协调多个服务正常运行,就必然引入一定的复杂度,这些复杂度主要体现在以下几个方面:

  1. 服务接口的调用,不再是同一进程内的方法调用,而是跨进程的网络调用,这会增加接口响应时间的增加。

    此时,我们就要选择高效的服务调用框架,同时,接口调用方需要知道服务部署在哪些机器的哪个端口上,这些信息需要存储在一个分布式一致性的存储中, 于是就需要引入服务注册中心, 这一点,是我在 24 讲会提到的内容。 不过在这里我想强调的是, 注册中心管理的是服务完整的生命周期,包括对于服务存活状态的检测。

  2. 多个服务之间有着错综复杂的依赖关系。

    一个服务会依赖多个其它服务,也会被多个服务所依赖,那么一旦被依赖的服务的性能出现问题,产生大量的慢请求,就会导致依赖服务的工作线程池中的线程被占满,那么依赖的服务也会出现性能问题。接下来,问题就会沿着依赖网,逐步向上蔓延,直到整个系统出现故障为止。

    为了避免这种情况的发生, 我们需要引入服务治理体系, 针对出问题的服务,采用熔断、降级、限流、超时控制的方法,使得问题被限制在单一服务中,保护服务网络中的其它服务不受影响。

  3. 服务拆分到多个进程后,一条请求的调用链路上,涉及多个服务,那么一旦这个请求的响应时间增长,或者是出现错误,我们就很难知道,是哪一个服务出现的问题。另外,整体系统一旦出现故障,很可能外在的表现是所有服务在同一时间都出现了问题,你在问题定位时,很难确认哪一个服务是源头,这就需要 引入分布式追踪工具,以及更细致的服务端监控报表。

我在 25 讲和 30 讲会详细的剖析这个内容, 在这里我想强调的是:

  • 监控报表关注的是,依赖服务和资源的宏观性能表现;
  • 分布式追踪关注的是,单一慢请求中的性能瓶颈分析

两者需要结合起来帮助你来排查问题。

以上这些微服务化后,在开发方面引入的问题,就是接下来,分布式服务篇和维护篇的主要讨论内容。

总的来说,微服务化是一个很大的话题,在微服务开发和维护时,你也许会在很短时间就把微服务拆分完成,但是你可能会花相当长的时间来完善服务治理体系。接下来的内容,会涉及一些常用微服务中间件的原理,和使用方式,你可以使用以下的方式更好地理解后面的内容:

  • 快速完成中间件的部署运行,建立对它感性的认识;
  • 阅读它的文档中,基本原理和架构设计部分;
  • 必要时,阅读它的源码,加深对它的理解,这样可以帮助你在维护你的微服务时,排查中间件引起的故障和解决性能问题。

课程小结

本节课,为了能够指导你更好地进行服务化的拆分,我带你了解了,微服务化拆分的原则,内容比较清晰。在这里,我想延伸一些内容:

  1. 「康威定律」提到,设计系统的组织,其产生的设计等同于组织间的沟通结构。通俗一点说,就是你的团队组织结构是什么样的,你的架构就会长成什么样。

    如果你的团队分为服务端开发团队,DBA 团队,运维团队,测试团队,那么你的架构就是一体化的,所有的团队共同为一个大系统负责,团队内成员众多,沟通成本就会很高;而如果你想实现微服务化的架构, 那么你的团队也要按照业务边界拆分, 每一个模块由一个自治的小团队负责,这个小团队里面有开发、测试、运维和 DBA,这样沟通就只发生在这个小团队内部,沟通的成本就会明显降低。

  2. 微服务化的一个目标是减少研发的成本,其中也包括沟通的成本,所以小团队内部成员不宜过多。

    按照亚马逊 CEO,贝佐斯的「两个披萨」的理论,如果两个披萨不够你的团队吃,那么你的团队就太大了,需要拆分,所以一个小团队包括开发、运维、测试以 6~8 个人为最佳;

  3. 如果你的团队人数不多,还没有做好微服务化的准备,而你又感觉到研发和部署的成本确实比较高,那么一个折中的方案是, 你可以优先做工程的拆分。

    比如说,如果你使用的是 Java 语言,你可以依据业务的边界,将代码拆分到不同的子工程中,然后子工程之间以 jar 包的方式依赖,这样每个子工程代码量减少,可以减少打包时间;并且子工程代码内部,可以做到高内聚低耦合,一定程度上减少研发的成本,也不失为一个不错的保守策略。

22 - CH23-分布式-RPC

在 21 讲和 22 讲中,你的团队已经决定对垂直电商系统做服务化拆分,以便解决扩展性和研发成本高的问题。与此同时,你们在不断学习的过程中还发现,系统做了服务化拆分之后,会引入一些新的问题,这些问题我在上节课提到过,归纳起来主要是两点:

  • 服务拆分单独部署后,引入的服务跨网络通信的问题;
  • 在拆分成多个小服务之后,服务如何治理的问题。

如果想要解决这两方面问题,你需要了解,微服务化所需要的中间件的基本原理,和使用技巧,那么本节课,我会带你掌握,解决第一点问题的核心组件: RPC 框架。

来思考这样一个场景: 你的垂直电商系统的 QPS 已经达到了每秒 2 万次,在做了服务化拆分之后,由于我们把业务逻辑,都拆分到了单独部署的服务中,那么假设你在完成一次完整的请求时,需要调用 4~5 次服务,计算下来,RPC 服务需要承载大概每秒 10 万次的请求。那么,你该如何设计 RPC 框架,来承载如此大的请求量呢?你要做的是:

  • 选择合适的网络模型,有针对性地调整网络参数,以优化网络传输性能;
  • 选择合适的序列化方式,以提升封包、解包的性能。

接下来,我从原理出发,让你对于 RPC 有一个理性的认识,这样你在设计 RPC 框架时,就可以清晰地知道自己的设计目标是什么了。

你所知道的 RPC

说到 RPC(Remote Procedure Call,远程过程调用),你不会陌生,它指的是通过网络,调用另一台计算机上部署服务的技术。

而 RPC 框架就封装了网络调用的细节,让你像调用本地服务一样,调用远程部署的服务。你也许觉得只有像 Dubbo、Grpc、Thrift 这些新兴的框架才算是 RPC 框架, 其实严格来说,你很早之前就接触到与 RPC 相关的技术了。

比如,Java 原生就有一套远程调用框架 叫做 RMI(Remote Method Invocation), 它可以让 Java 程序通过网络,调用另一台机器上的 Java 对象的方法。它是一种远程调用的方法,也是 J2EE 时代大名鼎鼎的 EJB 的实现基础。

时至今日,你仍然可以通过 Spring 的 RmiServiceExporter 将 Spring 管理的 bean 暴露成一个 RMI 的服务,从而继续使用 RMI 来实现跨进程的方法调用。之所以 RMI 没有像 Dubbo,Grpc 一样大火, 是因为它存在着一些缺陷:

  • RMI 使用专为 Java 远程对象定制的协议 JRMP(Java Remote Messaging Protocol)进行通信,这限制了它的通信双方,只能是 Java 语言的程序,无法实现跨语言通信;
  • RMI 使用 Java 原生的对象序列化方式,生成的字节数组空间较大,效率很差。

另一个你可能听过的技术是 Web Service, 它也可以认为是 RPC 的一种实现方式。它的优势是,使用 HTTP+SOAP 协议,保证了调用可以跨语言,跨平台。只要你支持 HTTP 协议,可以解析 XML,那么就能够使用 Web Service。在我来看,它由于使用 XML 封装数据,数据包大,性能还是比较差。

借上面几个例子,我主要是想告诉你, RPC 并不是互联网时代的产物,也不是服务化之后才衍生出来的技术,而是一种规范,只要是封装了网络调用的细节,能够实现远程调用其他服务,就可以算作是一种 RPC 技术了。

那么你的垂直电商项目在使用 RPC 框架之后, 会产生什么变化呢?

在我来看,在性能上的变化是不可忽视的, 我给你举个例子。 比方说,你的电商系统中,商品详情页面需要商品数据、评论数据还有店铺数据,如果在一体化的架构中,你只需要从商品库,评论库和店铺库获取数据就可以了,不考虑缓存的情况下有三次网络请求。

但是,如果独立出商品服务、评论服务和店铺服务之后,那么就需要分别调用这三个服务,而这三个服务又会分别调用各自的数据库,这就是六次网络请求。如果你服务拆分的更细粒度,那么多出的网络调用就会越多,请求的延迟就会更长,而这就是你为了提升系统的扩展性,在性能上所付出的代价。

NAME

那么,我们要如果优化 RPC 的性能,从而尽量减少网络调用,对于性能的影响呢?在这里,你首先需要了解一次 RPC 的调用都经过了哪些步骤,因为这样,你才可以针对这些步骤中可能存在的性能瓶颈点提出优化方案。 步骤如下:

  • 在 一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流;
  • 然后客户端将二进制流,通过网络发送给服务端;
  • 服务端接收到二进制流之后,将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式,调用对应的方法得到返回值;
  • 服务端将返回值序列化,再通过网络发送给客户端;
  • 客户端对结果反序列化之后,就可以得到调用的结果了。

过程图如下:

NAME

从这张图中你可以看到,有网络传输的过程,也有将请求序列化和反序列化的过程, 所以,如果要提升 RPC 框架的性能,需要从 网络传输和序列化 两方面来优化。

如何提升网络传输性能

在网络传输优化中,你首要做的,是选择一种高性能的 I/O 模型。所谓 I/O 模型,就是我们处理 I/O 的方式。而一般单次 I/O 请求会分为两个阶段,每个阶段对于 I/O 的处理方式是不同的。

首先,I/O 会经历一个等待资源的阶段, 比方说,等待网络传输数据可用,在这个过程中我们对 I/O 会有两种处理方式:

  • 阻塞。指的是在数据不可用时,I/O 请求一直阻塞,直到数据返回;
  • 非阻塞。指的是数据不可用时,I/O 请求立即返回,直到被通知资源可用为止。

然后是使用资源的阶段, 比如说从网络上接收到数据,并且拷贝到应用程序的缓冲区里面。在这个阶段我们也会有两种处理方式:

  • 同步处理。指的是 I/O 请求在读取或者写入数据时会阻塞,直到读取或者写入数据完成;
  • 异步处理。指的是 I/O 请求在读取或者写入数据时立即返回,当操作系统处理完成 I/O 请求,并且将数据拷贝到用户提供的缓冲区后,再通知应用 I/O 请求执行完成。

将这两个阶段的四种处理方式,做一些排列组合,再做一些补充,就得到了我们常见的五种 I/O 模型:

  • 同步阻塞 I/O
  • 同步非阻塞 I/O
  • 同步多路 I/O 复用
  • 信号驱动 I/O
  • 异步 I/O

这五种 I/O 模型,你需要理解它们的区别和特点,不过在理解上你可能会有些难度,所以我来做个比喻,方便你理解。

我们来把 I/O 过程比喻成烧水倒水的过程,等待资源(就是烧水的过程),使用资源(就是倒水的过程):

  • 如果你站在炤台边上一直等着(等待资源)水烧开,然后倒水(使用资源),那么就是同步阻塞 I/O;
  • 如果你偷点儿懒,在烧水的时候躺在沙发上看会儿电视(不再时时刻刻等待资源),但是还是要时不时的去看看水开了没有,一旦水开了,马上去倒水(使用资源),那么这就是同步非阻塞 I/O;
  • 如果你想要洗澡,需要同时烧好多壶水,那你就在看电视的间隙去看看哪壶水开了(等待多个资源),哪一壶开了就先倒哪一壶,这样就加快了烧水的速度,这就是同步多路 I/O 复用;
  • 不过你发现自己总是跑厨房去看水开了没,太累了,于是你考虑给你的水壶加一个报警器(信号),只要水开了就马上去倒水,这就是信号驱动 I/O;
  • 最后一种就高级了,你发明了一个智能水壶,在水烧好后自动就可以把水倒好,这就是异步 I/O。

这五种 I/O 模型中最被广泛使用的是 多路 I/O 复用, Linux 系统中的 select、epoll 等系统调用都是支持多路 I/O 复用模型的,Java 中的高性能网络框架 Netty 默认也是使用这种模型。所以,我们可以选择它。

那么,选择好了一种高性能的 I/O 模型,是不是就能实现,数据在网络上的高效传输呢?其实并没有那么简单,网络性能的调优涉及很多方面, 其中不可忽视的一项就是网络参数的调优, 接下来,我带你了解其中一个典型例子。当然,你可以结合网络基础知识,以及成熟 RPC 框架(比如 Dubbo)的源码来深入了解,网络参数调优的方方面面。

在之前的项目中, 我的团队曾经写过一个简单的 RPC 通信框架。在进行测试的时候发现,远程调用一个空业务逻辑的方法时,平均响应时间居然可以到几十毫秒,这明显不符合我们的预期,在我们看来,运行一个空的方法,应该在 1 毫秒之内可以返回。于是,我先在测试的时候使用 tcpdump 抓了包,发现一次请求的 Ack 包竟然要经过 40ms 才返回。在网上 google 了一下原因,发现原因和一个叫做 tcp_nodelay 的参数有关。 这个参数是什么作用呢?

tcp 协议的包头有 20 字节,ip 协议的包头也有 20 字节,如果仅仅传输 1 字节的数据,在网络上传输的就有 20 + 20 + 1 = 41 字节,其中真正有用的数据只有 1 个字节,这对效率和带宽是极大的浪费。所以在 1984 年的时候,John Nagle 提出了以他的名字命名的 Nagle`s 算法, 他期望:

如果是连续的小数据包,大小没有一个 MSS(Maximum Segment Size,最大分段大小),并且还没有收到之前发送的数据包的 Ack 信息,那么这些小数据包就会在发送端暂存起来,直到小数据包累积到一个 MSS,或者收到一个 Ack 为止。

这原本是为了减少不必要的网络传输,但是如果接收端开启了 DelayedACK(延迟 ACK 的发送,这样可以合并多个 ACK,提升网络传输效率),那就会发生, 发送端发送第一个数据包后,接收端没有返回 ACK,这时发送端发送了第二个数据包,因为 Nagle`s 算法的存在,并且第一个发送包的 ACK 还没有返回,所以第二个包会暂存起来。而 DelayedACK 的超时时间,默认是 40ms,所以一旦到了 40ms,接收端回给发送端 ACK,那么发送端才会发送第二个包, 这样就增加了延迟。

解决的方式非常简单: 只要在 socket 上开启 tcp_nodelay 就好了,这个参数关闭了 Nagle`s 算法,这样发送端就不需要等到上一个发送包的 ACK 返回,直接发送新的数据包就好了。这对于强网络交互的场景来说非常的适用,基本上,如果你要自己实现一套网络框架,tcp_nodelay 这个参数最好是要开启的。

选择合适的序列化方式

在对网络数据传输完成调优之后,另外一个需要关注的点就是, 数据的序列化和反序列化。 通常所说的序列化,是将传输对象转换成二进制串的过程,而反序列化则是相反的动作,是将二进制串转换成对象的过程。

从上面的 RPC 调用过程中你可以看到,一次 RPC 调用需要经历两次数据序列化的过程,和两次数据反序列化的过程,可见它们对于 RPC 的性能影响是很大的, 那么我们在选择序列化方式的时候需要考虑哪些因素呢?

首先需要考虑的肯定是性能嘛,性能包括时间上的开销和空间上的开销,时间上的开销就是序列化和反序列化的速度,这是显而易见需要重点考虑的,而空间上的开销则是序列化后的二进制串的大小,过大的二进制串也会占据传输带宽,影响传输效率。

除去性能之外,我们需要考虑的是它是否可以跨语言,跨平台,这一点也非常重要,因为一般的公司的技术体系都不是单一的,使用的语言也不是单一的,那么如果你的 RPC 框架中传输的数据只能被一种语言解析,那么这无疑限制了框架的使用。

另外,扩展性也是一个需要考虑的重点问题。你想想,如果对象增加了一个字段就会造成传输协议的不兼容,导致服务调用失败,这会是多么可怕的事情。

综合上面的几个考虑点,在我看来, 我们的序列化备选方案主要有以下几种:

首先是大家熟知的 JSON,它起源于 JavaScript,是一种最广泛使用的序列化协议,它的优势简单易用,人言可读,同时在性能上相比 XML 有比较大的优势。

另外的 Thrift 和 Protobuf 都是需要引入 IDL(Interface description language)的,也就是需要按照约定的语法写一个 IDL 文件,然后通过特定的编译器将它转换成各语言对应的代码,从而实现跨语言的特点。

Thrift 是 Facebook 开源的高性能的序列化协议,也是一个轻量级的 RPC 框架;Protobuf 是谷歌开源的序列化协议。它们的共同特点是,无论在空间上还是时间上都有着很高的性能,缺点就是由于 IDL 存在带来一些使用上的不方便。

那么,你要如何选择这几种序列化协议呢? 这里我给你几点建议:

  • 如果对于性能要求不高,在传输数据占用带宽不大的场景下,可以使用 JSON 作为序列化协议;
  • 如果对于性能要求比较高,那么使用 Thrift 或者 Protobuf 都可以。而 Thrift 提供了配套的 RPC 框架,所以想要一体化的解决方案,你可以优先考虑 Thrift;
  • 在一些存储的场景下,比如说你的缓存中存储的数据占用空间较大,那么你可以考虑使用 Protobuf 替换 JSON,作为存储数据的序列化方式。

课程小结

为了优化 RPC 框架的性能,本节课,我带你了解了网络 I/O 模型和序列化方式的选择,它们是实现高并发 RPC 框架的要素,总结起来有三个要点:

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

在学习本节课的过程中,我建议你阅读一下,成熟的 RPC 框架的源代码。比如,阿里开源的 Dubbo,微博的 Motan 等等,理解它们的实现原理和细节,这样你会更有信心维护好你的微服务系统;同时,你也可以从优秀的代码中,学习到代码设计的技巧,比如说 Dubbo 对于 RPC 的抽象,SPI 扩展点的设计,这样可以有助你提升代码能力。

当然了,本节课我不仅仅想让你了解 RPC 框架实现的一些原理,更想让你了解在做网络编程时,需要考虑哪些关键点,这样你在设计此类型的系统时,就会有一些考虑的方向和思路了。

23 - CH24-分布式-注册中心

上一节课,我带你了解了 RPC 框架实现中的一些关键的点,你通过 RPC 框架,能够解决服务之间,跨网络通信的问题,这就完成了微服务化改造的基础。

但是在服务拆分之后,你需要维护更多的细粒度的服务,而你需要面对的第一个问题就是,如何让 RPC 客户端知道服务端部署的地址,这就是我们今天要讲到的,服务注册与发现的问题。

你所知道的服务发现

服务注册和发现不是一个新的概念,你在之前的实际项目中也一定了解过,只是你可能没怎么注意罢了。比如说,你知道 Nginx 是一个反向代理组件,那么 Nginx 需要知道,应用服务器的地址是什么,这样才能够将流量透传到应用服务器上, 这就是服务发现的过程。

那么 Nginx 是怎么实现的呢? 它是把应用服务器的地址配置在了文件中。

这固然是一种解决的思路,实际上,我在早期的项目中也是这么做的。那时,项目刚刚做了服务化拆分,RPC 服务端的地址,就是配置在了客户端的代码中,不过,这样做之后出现了几个问题:

  • 首先在紧急扩容的时候,就需要修改客户端配置后,重启所有的客户端进程,操作时间比较长;
  • 其次,一旦某一个服务器出现故障时,也需要修改所有客户端配置后重启,无法快速修复,更无法做到自动恢复;
  • 最后,RPC 服务端上线无法做到提前摘除流量,这样在重启服务端的时候,客户端发往被重启服务端的请求还没有返回,会造成慢请求甚至请求失败。

因此,我们考虑使用 注册中心 来解决这些问题。

目前业界有很多可供你来选择的注册中心组件,比如说老派的 ZooKeeper,Kubernetes 使用的 ETCD,阿里的微服务注册中心 Nacos,Spring Cloud 的 Eureka 等等。

这些注册中心的基本功能有两点:

  • 其一是提供了服务地址的存储;
  • 其二是当存储内容发生变化时,可以将变更的内容推送给客户端。

第二个功能是我们使用注册中心的主要原因。因为无论是,当我们需要紧急扩容,还是在服务器发生故障时,需要快速摘除节点,都不用重启服务器就可以实现了。使用了注册中心组件之后,RPC 的通信过程就变成了下面这个样子:

NAME

从图中,你可以看到一个完整的,服务注册和发现的过程:

  • 客户端会与注册中心建立连接,并且告诉注册中心,它对哪一组服务感兴趣;
  • 服务端向注册中心注册服务后,注册中心会将最新的服务注册信息通知给客户端;
  • 客户端拿到服务端的地址之后就可以向服务端发起调用请求了。

从这个过程中可以看出,有了注册中心之后,服务节点的增加和减少对于客户端就是透明的。这样,除了可以实现不重启客户端,就能动态地变更服务节点以外,还可以 实现优雅关闭的功能。

优雅关闭是你在系统研发过程中,必须要考虑的问题。因为如果暴力地停止服务,那么已经发送给服务端的请求,来不及处理服务就被杀掉了,就会造成这部分请求失败,服务就会有波动。所以,服务在退出的时候,都需要先停掉流量,再停止服务,这样服务的关闭才会更平滑,比如说,消息队列处理器就是要将所有,已经从消息队列中读出的消息,处理完之后才能退出。

对于 RPC 服务来说, 我们可以先将 RPC 服务从注册中心的服务列表中删除掉,然后观察 RPC 服务端没有流量之后,再将服务端停掉。有了优雅关闭之后,RPC 服务端再重启的时候,就会减少对客户端的影响。

在这个过程中,服务的上线和下线是由服务端主动向注册中心注册、和取消注册来实现的,这在正常的流程中是没有问题的。 可是,如果某一个服务端意外故障, 比如说机器掉电,网络不通等情况,服务端就没有办法向注册中心通信,将自己从服务列表中删除,那么客户端也就不会得到通知,它就会继续向一个故障的服务端发起请求,也就会有错误发生了。那这种情况如何来避免呢?其实,这种情况是一个服务状态管理的问题。

服务状态管理如何来做

针对上面我提到的问题, 我们一般会有两种解决思路。

主动探测

第一种思路是主动探测, 方法是这样的:

你的 RPC 服务要打开一个端口,然后由注册中心每隔一段时间(比如 30 秒)探测这些端口是否可用,如果可用就认为服务仍然是正常的,否则就可以认为服务不可用,那么注册中心就可以把服务从列表里面删除了。

NAME

微博早期的注册中心就是采用这种方式,但是后面出现的两个问题,让我们不得不对它做改造。

第一个问题是: 所有的 RPC 服务端都需要,开放一个统一的端口给注册中心探测,那时候还没有容器化,一台物理机上会混合部署很多的服务,你需要开放的端口很可能已经被占用,这样会造成 RPC 服务启动失败。

还有一个问题是: 如果 RPC 服务端部署的实例比较多,那么每次探测的成本也会比较高,探测的时间也比较长,这样当一个服务不可用时,可能会有一段时间的延迟,才会被注册中心探测到。

因此,我们后面把它改造成了心跳模式。

心跳模式

这也是大部分注册中心提供的,检测连接上来的 RPC 服务端是否存活的方式,比如 Eureka、ZooKeeper, 在我来看,这种心跳机制可以这样实现:

注册中心为每一个连接上来的 RPC 服务节点,记录最近续约的时间 ,RPC 服务节点在启动注册到注册中心后,就按照一定的时间间隔(比如 30 秒),向注册中心发送心跳包。注册中心在接受到心跳包之后,会更新这个节点的最近续约时间。然后,注册中心会启动一个定时器,定期检测当前时间和节点,最近续约时间的差值,如果达到一个阈值(比如说 90 秒),那么认为这个服务节点不可用。

NAME

在实际的使用中, 心跳机制相比主动探测的机制,适用范围更广,如果你的服务也需要检测是否存活,那么也可以考虑使用心跳机制来检测。

接着说回来,有了心跳机制之后,注册中心就可以管理注册的服务节点的状态了,也让你的注册中心成为了整体服务最重要的组件,因为一旦它出现问题或者代码出现 Bug,那么很可能会导致整个集群的故障,给你举一个真实的案例。

在我之前的一个项目中, 工程是以「混合云」的方式部署的,也就是一部分节点部署在自建机房中,一部分节点部署在云服务器上,每一个机房都部署了自研的一套注册中心,每套注册中心中都保存了全部节点的数据。

这套自研的注册中心使用 Redis 作为最终的存储,而在自建机房和云服务器上的注册中心,共用同一套 Redis 存储资源。由于「混合云」还处在测试阶段,所以,所有的流量还都在自建机房,自建机房和云服务器之前的专线带宽还比较小,部署结构如下:

NAME

在测试的过程中,系统运行稳定,但是某一天早上五点,我突然发现,所有的服务节点都被摘除了,客户端因为拿不到服务端的节点地址列表全部调用失败,整体服务宕机。经过排查我发现,云服务器上部署的注册中心,竟然将所有的服务节点全部删除了!进一步排查之后, 原来是自研注册中心出现了 Bug。

在正常的情况下,无论是自建机房,还是云服务器上的服务节点,都会向各自机房的注册中心注册地址信息,并且发送心跳。而这些地址信息,以及服务的最近续约时间,都是存储在 Redis 主库中,各自机房的注册中心,会读各自机房的从库来获取最近续约时间,从而判断服务节点是否有效。

Redis 的主从同步数据是通过专线来传输的,出现故障之前,专线带宽被占满,导致主从同步延迟。这样一来,云上部署的 Redis 从库中存储的最近续约时间,就没有得到及时更新,随着主从同步延迟越发严重,最终,云上部署的注册中心发现了,当前时间与最近续约时间的差值,超过了摘除的阈值,所以将所有的节点摘除,从而导致了故障。

有了这次惨痛的教训, 我们给注册中心增加了保护的策略: 如果摘除的节点占到了服务集群节点数的 40%,就停止摘除服务节点,并且给服务的开发同学和,运维同学报警处理(这个阈值百分比可以调整,保证了一定的灵活性)。

据我所知, Eureka 也采用了类似的策略,来避免服务节点被过度摘除,导致服务集群不足以承担流量的问题。如果你使用的是 ZooKeeper 或者 ETCD 这种无保护策略的分布式一致性组件,那你可以考虑在客户端,实现保护策略的逻辑,比如说当摘除的节点超过一定比例时,你在 RPC 客户端就不再处理变更通知,你可以依据自己的实际情况来实现。

除此之外,在实际项目中,我们还发现注册中心另一个重要的问题就是「通知风暴」。你想一想,变更一个服务的一个节点,会产生多少条推送消息?假如你的服务有 100 个调用者,有 100 个节点,那么变更一个节点会推送 100 * 100 = 10000 个节点的数据。那么如果多个服务集群同时上线或者发生波动时,注册中心推送的消息就会更多,会严重占用机器的带宽资源,这就是我所说的「通知风暴」。 那么怎么解决这个问题呢? 你可以从以下几个方面来思考:

  • 首先,要控制一组注册中心管理的服务集群的规模,具体限制多少没有统一的标准,你需要结合你的业务以及注册中心的选型来考虑,主要考察的指标就是注册中心服务器的峰值带宽;
  • 其次,你也可以通过扩容注册中心节点的方式来解决;
  • 再次,你可以规范一下对于注册中心的使用方式,如果只是变更某一个节点,那么只需要通知这个节点的变更信息即可;
  • 最后,如果是自建的注册中心,你也可以在其中加入一些保护策略,比如说如果通知的消息量达到某一个阈值就停止变更通知。

其实,服务的注册和发现,归根结底是服务治理中的一环, **服务治理(service governance), ** 其实更直白的翻译应该是服务的管理,也就是解决多个服务节点,组成集群的时候,产生的一些复杂的问题。为了帮助你理解, 我来做个简单的比喻。

你可以把集群看作是一个微型的城市,把道路看做是组成集群的服务,把行走在道路上的车当做是流量,那么服务治理就是对于整个城市道路的管理。

如果你新建了一条街道(相当于启动了一个新的服务节点),那么就要通知所有的车辆(流量)有新的道路可以走了;你关闭了一条街道,你也要通知所有车辆不要从这条路走了, 这就是服务的注册和发现。

我们在道路上安装监控,监视每条道路的流量情况, 这就是服务的监控。

道路一旦出现拥堵或者道路需要维修,那么就需要暂时封闭这条道路,由城市来统一调度车辆,走不堵的道路, 这就是熔断以及引流。

道路之间纵横交错四通八达,一旦在某条道路上出现拥堵,但是又发现这条道路从头堵到尾,说明事故并不是发生在这条道路上,那么就需要从整体链路上来排查事故究竟处在哪个位置, 这就是分布式追踪。

不同道路上的车辆有多有少,那么就需要有一个警察来疏导,在某一个时间走哪一条路会比较快, 这就是负载均衡。

而这些问题,我会在后面的课程中针对性地讲解。

课程小结

本节课,我带你了解了在微服务架构中,注册中心是如何实现服务的注册和发现的,以及在实现中遇到的一些坑,除此之外,我还带你了解了服务治理的含义,以及后续我们会讲到的一些技术点。在这节课中,我想让你明确的重点如下:

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

你看,注册中心虽然是一种简单易懂的分布式组件,但是它在整体架构中的位置至关重要,不容忽视。同时,在它的设计方案中,也蕴含了一些系统设计的技巧,比如上,面提到的服务状态检测的方式,还有上面提到的优雅关闭的方式,了解注册中心的原理,会给你之后的研发工作提供一些思路。

24 - CH25-分布式-追踪

经过前面几节课的学习,你的垂直电商系统在引入 RPC 框架,和注册中心之后已经完成基本的服务化拆分了,系统架构也有了改变:

NAME

现在,你的系统运行平稳,老板很高兴,你也安心了很多。而且你认为,在经过了服务化拆分之后,服务的可扩展性增强了很多,可以通过横向扩展服务节点的方式,进行平滑地扩容了,对于应对峰值流量也更有信心了。

但是这时出现了问题: 你通过监控发现,系统的核心下单接口在晚高峰的时候,会有少量的慢请求,用户也投诉在 APP 上下单时,等待的时间比较长。而下单的过程可能会调用多个 RPC 服务,或者使用多个资源,一时之间,你很难快速判断,究竟是哪个服务或者资源出了问题,从而导致整体流程变慢, 于是,你和你的团队开始想办法如何排查这个问题。

一体化架构中的慢请求排查如何做

因为在分布式环境下,请求要在多个服务之间调用,所以对于慢请求问题的排查会更困难, 我们不妨从简单的入手, 先看看在一体化架构中,是如何排查这个慢请求的问题的。

最简单的思路是:打印下单操作的每一个步骤的耗时情况,然后通过比较这些耗时的数据,找到延迟最高的一步,然后再来看看这个步骤要如何的优化。如果有必要的话,你还需要针对步骤中的子步骤,再增加日志来继续排查, 简单的代码就像下面这样:

long start = System.currentTimeMillis();
processA();
Logs.info("process A cost " + (System.currentTimeMillis() - start));// 打印 A 步骤的耗时
start = System.currentTimeMillis();
processB();
Logs.info("process B cost " + (System.currentTimeMillis() - start));// 打印 B 步骤的耗时
start = System.currentTimeMillis();
processC();
Logs.info("process C cost " + (System.currentTimeMillis() - start));// 打印 C 步骤的耗时

这是最简单的实现方式,打印出日志后,我们可以登录到机器上,搜索关键词来查看每个步骤的耗时情况。

虽然这个方式比较简单,但你可能很快就会遇到问题: 由于同时会有多个下单请求并行处理,所以,这些下单请求的每个步骤的耗时日志,是相互穿插打印的。你无法知道这些日志,哪些是来自于同一个请求,也就不能很直观地看到,某一次请求耗时最多的步骤是哪一步了。那么,你要如何把单次请求,每个步骤的耗时情况串起来呢?

一个简单的思路是: 给同一个请求的每一行日志,增加一个相同的标记。这样,只要拿到这个标记就可以查询到这个请求链路上,所有步骤的耗时了,我们把这个标记叫做 requestId,我们可以在程序的入口处生成一个 requestId,然后把它放在线程的上下文中,这样就可以在需要时,随时从线程上下文中获取到 requestId 了。简单的代码实现就像下面这样:

String requestId = UUID.randomUUID().toString();
ThreadLocal<String> tl = new ThreadLocal<String>(){
    @Override
    protected String initialValue() {
        return requestId;
    }
}; //requestId 存储在线程上下文中
long start = System.currentTimeMillis();
processA();
Logs.info("rid : " + tl.get() + ", process A cost " + (System.currentTimeMillis() - start)); // 日志中增加 requestId
start = System.currentTimeMillis();
processB();
Logs.info("rid : " + tl.get() + ", process B cost " + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
processC();
Logs.info("rid : " + tl.get() + ", process C cost " + (System.currentTimeMillis() - start));

有了 requestId,你就可以清晰地了解一个调用链路上的耗时分布情况了。

于是,你给你的代码增加了大量的日志,来排查下单操作缓慢的问题。 很快, 你发现是某一个数据库查询慢了才导致了下单缓慢,然后你优化了数据库索引,问题最终得到了解决。

正当你要松一口气的时候,问题接踵而至: 又有用户反馈某些商品业务打开缓慢;商城首页打开缓慢。你开始焦头烂额地给代码中增加耗时日志,而这时你意识到,每次排查一个接口就需要增加日志、重启服务, 这并不是一个好的办法,于是你开始思考解决的方案。

其实,从我的经验出发来说, 一个接口响应时间慢,一般是出在跨网络的调用上,比如说请求数据库、缓存或者依赖的第三方服务。所以,我们只需要针对这些调用的客户端类,做切面编程,通过插入一些代码打印它们的耗时就好了。

说到切面编程(AOP)你应该并不陌生,它是面向对象编程的一种延伸,可以在不修改源代码的前提下,给应用程序添加功能,比如说鉴权,打印日志等等。如果你对切面编程的概念理解的还不透彻,那我给你做个比喻, 帮你理解一下。

这就像开发人员在向代码仓库提交代码后,他需要对代码编译、构建、执行单元测试用例,以保证提交的代码是没有问题的。但是,如果每个人提交了代码都做这么多事儿,无疑会对开发同学造成比较大的负担,那么你可以配置一个持续集成的流程,在提交代码之后,自动帮你完成这些操作,这个持续集成的流程就可以认为是一个切面。

一般来说,切面编程的实现分为两类:

  • 一类是静态代理,典型的代表是 AspectJ,它的特点是在编译期做切面代码注入;
  • 另一类是动态代理,典型的代表是 Spring AOP,它的特点是在运行期做切面代码注入。

这两者有什么差别呢? 以 Java 为例,源代码 Java 文件先被 Java 编译器,编译成 Class 文件,然后 Java 虚拟机将 Class 装载进来之后,进行必要的验证和初始化后就可以运行了。

静态代理是在编译期插入代码,增加了编译的时间,给你的直观感觉就是启动的时间变长了,但是一旦在编译期插入代码完毕之后,在运行期就基本对于性能没有影响。

而动态代理不会去修改生成的 Class 文件,而是会在运行期生成一个代理对象,这个代理对象对源对象做了字节码增强,来完成切面所要执行的操作。由于在运行期需要生成代理对象,所以动态代理的性能要比静态代理要差。

我们做切面的原因,是想生成一些调试的日志,所以我们期望尽量减少对于原先接口性能的影响。 因此,我推荐采用静态代理的方式,实现切面编程。

如果你的系统中需要增加切面,来做一些校验、限流或者日志打印的工作, 我也建议你考虑使用静态代理的方式, 使用 AspectJ 做切面的简单代码实现就像下面这样:

@Aspect
public class Tracer {
    @Around(value = "execution(public methodsig)", argNames = "pjp") //execution 内替换要做切面的方法签名
    public Object trace(ProceedingJoinPoint pjp) throws Throwable {
        TraceContext traceCtx = TraceContext.get(); // 获取追踪上下文,上下文的初始化可以在程序入口处
        String requestId = reqCtx.getRequestId(); // 获取 requestId
        String sig = pjp.getSignature().toShortString(); // 获取方法签名
        boolean isSuccessful = false;
        String errorMsg = "";
        Object result = null;
        long start = System.currentTimeMillis();
        try {
            result = pjp.proceed();
            isSuccessful = true;
            return result;
        } catch (Throwable t) {
            isSuccessful = false;
            errorMsg = t.getMessage();
            return result;
        } finally {
            long elapseTime = System.currentTimeMillis() - start;
            Logs.info("rid : " + requestId + ", start time: " + start + ", elapseTime: " + elapseTime + ", sig: " + sig + ", isSuccessful: " + isSuccessful + ", errorMsg: " + errorMsg  );
        }
    }
}

这样,你就在你的系统的每个接口中,打印出了所有访问数据库、缓存、外部接口的耗时情况,一次请求可能要打印十几条日志,如果你的电商系统的 QPS 是 10000 的话,就是每秒钟会产生十几万条日志,对于磁盘 I/O 的负载是巨大的, 那么这时,你就要考虑如何减少日志的数量。

你可以考虑对请求做采样, 采样的方式也简单,比如你想采样 10% 的日志,那么你可以只打印 requestId%10==0 的请求。

有了这些日志之后,当给你一个 requestId 的时候,你发现自己并不能确定这个请求到了哪一台服务器上,所以你不得不登陆所有的服务器,去搜索这个 requestId 才能定位请求。这样无疑会增加问题排查的时间。

你可以考虑的解决思路是: 把日志不打印到本地文件中,而是发送到消息队列里,再由消息处理程序写入到集中存储中,比如 Elasticsearch。这样,你在排查问题的时候,只需要拿着 requestId 到 Elasticsearch 中查找相关的记录就好了。在加入消息队列和 Elasticsearch 之后,我们这个排查程序的架构图也会有所改变:

NAME

我来总结一下,为了排查单次请求响应时间长的原因,我们主要做了哪些事情:

  1. 在记录打点日志时,我们使用 requestId 将日志串起来,这样方便比较一次请求中的多个步骤的耗时情况;
  2. 我们使用静态代理的方式做切面编程,避免在业务代码中,加入大量打印耗时的日志的代码,减少了对于代码的侵入性,同时编译期的代码注入可以减少;
  3. 我们增加了日志采样率,避免全量日志的打印;
  4. 最后为了避免在排查问题时,需要到多台服务器上搜索日志,我们使用消息队列,将日志集中起来放在了 Elasticsearch 中。

如何来做分布式 Trace

你可能会问:题目既然是「分布式 Trace:横跨几十个分布式组件的慢请求要如何排查?」,那么我为什么要花费大量的篇幅,来说明在一体化架构中如何排查问题呢? 这是因为在分布式环境下, 你基本上也是依据上面,我提到的这几点来构建分布式追踪的中间件的。

在一体化架构中,单次请求的所有的耗时日志,都被记录在一台服务器上,而在微服务的场景下,单次请求可能跨越多个 RPC 服务,这就造成了,单次的请求的日志会分布在多个服务器上。

当然,你也可以通过 requestId 将多个服务器上的日志串起来,但是仅仅依靠 requestId 很难表达清楚服务之间的调用关系,所以从日志中,就无法了解服务之间是谁在调用谁。因此,我们采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系(这里 traceId 就是 requestId),也就是使用 traceId 串起单次请求,用 spanId 记录每一次 RPC 调用。 说起来可能比较抽象,我给你举一个具体的例子。

比如,你的请求从用户端过来,先到达 A 服务,A 服务会分别调用 B 和 C 服务,B 服务又会调用 D 和 E 服务。

NAME

我来给你讲讲图中的内容:

  • 用户到 A 服务之后会初始化一个 traceId 为 100,spanId 为 1;
  • A 服务调用 B 服务时,traceId 不变,而 spanId 用 1.1 标识,代表上一级的 spanId 是 1,这一级的调用次序是 1;
  • A 调用 C 服务时,traceId 依然不变,spanId 则变为了 1.2,代表上一级的 spanId 还是 1,而调用次序则变成了 2,以此类推。

通过这种方式,我们可以在日志中,清晰地看出服务的调用关系是如何的,方便在后续计算中调整日志顺序,打印出完整的调用链路。

那么 spanId 是何时生成的,又是如何传递的呢? 这部分内容可以算作一个延伸点,能够帮你了解分布式 trace 中间件的实现原理。

首先,A 服务在发起 RPC 请求服务 B 前,先从线程上下文中获取当前的 traceId 和 spanId,然后,依据上面的逻辑生成本次 RPC 调用的 spanId,再将 spanId 和 traceId 序列化后,装配到请求体中,发送给服务方 B。

服务方 B 获取请求后,从请求体中反序列化出 spanId 和 traceId,同时设置到线程上下文中,以便给下次 RPC 调用使用。在服务 B 调用完成返回响应前,计算出服务 B 的执行时间发送给消息队列。

当然,在服务 B 中,你依然可以使用切面编程的方式,得到所有调用的数据库、缓存、HTTP 服务的响应时间,只是在发送给消息队列的时候,要加上当前线程上下文中的 spanId 和 traceId。

这样,无论是数据库等资源的响应时间,还是 RPC 服务的响应时间就都汇总到了消息队列中,在经过一些处理之后,最终被写入到 Elasticsearch 中以便给开发和运维同学查询使用。

而在这里,你大概率会遇到的问题还是性能的问题,也就是因为引入了分布式追踪中间件,导致对于磁盘 I/O 和网络 I/O 的影响, 而我给你的「避坑」指南就是: 如果你是自研的分布式 trace 中间件,那么一定要提供一个开关,方便在线上随时将日志打印关闭;如果使用开源的组件,可以开始设置一个较低的日志采样率,观察系统性能情况再调整到一个合适的数值。

课程小结

本节课我带你了解了在一体化架构和服务化架构中,你要如何排查单次慢请求中,究竟哪一个步骤是瓶颈,这里你需要了解的主要有以下几个重点:

  • 服务的追踪的需求主要有两点:
    • 一点对代码要无侵入,你可以使用切面编程的方式来解决;
    • 另一点是性能上要低损耗,我建议你采用静态代理和日志采样的方式,来尽量减少追踪日志对于系统性能的影响;
  • 无论是单体系统还是服务化架构,无论是服务追踪还是业务问题排查,你都需要在日志中增加 requestId,这样可以将你的日志串起来,给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成,在请求业务接口的时候传递给服务端,那么就可以把客户端的日志体系也整合进来,对于问题的排查帮助更大。

其实,分布式追踪系统不是一项新的技术,而是若干项已有技术的整合,在实现上并不复杂,却能够帮助你实现跨进程调用链展示、服务依赖分析,在性能优化和问题排查方面提供数据上的支持。所以,在微服务化过程中,它是一个必选项,无论是采用 Zipkin,Jaeger 这样的开源解决方案,还是团队内自研,你都应该在微服务化完成之前,尽快让它发挥应有的价值。

25 - CH26-分布式-负载均衡

在基础篇中,我提到了高并发系统设计的三个通用方法:缓存、异步和横向扩展,到目前为止,你接触到了缓存的使用姿势,也了解了,如何使用消息队列异步处理业务逻辑,那么本节课,我将带你了解一下,如何提升系统的横向扩展能力。

在之前的课程中,我也提到过提升系统横向扩展能力的一些案例。比如,08 讲提到,可以通过部署多个从库的方式,来提升数据库的扩展能力,从而提升数据库的查询性能,那么就需要借助组件,将查询数据库的请求,按照一些既定的策略分配到多个从库上,这是负载均衡服务器所起的作用,而我们一般使用 DNS 服务器 来承担这个角色。

不过在实际的工作中,你经常使用的负载均衡的组件应该算是 Nginx,它的作用是承接前端的 HTTP 请求,然后将它们按照多种策略,分发给后端的多个业务服务器上。这样,我们可以随时通过扩容业务服务器的方式,来抵挡突发的流量高峰。与 DNS 不同的是,Nginx 可以在域名和请求 URL 地址的层面做更细致的流量分配,也提供更复杂的负载均衡策略

你可能会想到,在微服务架构中,我们也会启动多个服务节点,来承接从用户端到应用服务器的请求,自然会需要一个负载均衡服务器,作为流量的入口,实现流量的分发。那么在微服务架构中,如何使用负载均衡服务器呢?

在回答这些问题之前,我先带你了解一下,常见的负载均衡服务器都有哪几类,因为这样,你就可以依据不同类型负载均衡服务器的特点做选择了。

负载均衡服务器的种类

负载均衡的含义是: 将负载(访问的请求)「均衡」地分配到多个处理节点上。这样可以减少单个处理节点的请求量,提升整体系统的性能。

同时,负载均衡服务器作为流量入口,可以对请求方屏蔽服务节点的部署细节,实现对于业务方无感知的扩容。它就像交通警察,不断地疏散交通,将汽车引入合适的道路上。

而在我看来, 负载均衡服务大体上可以分为两大类:一类是 代理类的负载均衡服务 ;另一类是 客户端负载均衡服务

代理类的负载均衡服务

代理类的负载均衡服务,以单独的服务方式部署,所有的请求都要先经过负载均衡服务,在负载均衡服务中,选出一个合适的服务节点后,再由负载均衡服务,调用这个服务节点来实现流量的分发。

NAME

由于这类服务需要承担全量的请求,所以对于性能的要求极高。代理类的负载均衡服务有很多开源实现,比较著名的有 LVS,Nginx 等等。LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载;而 Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载(你可以回顾一下02 讲的内容)。

在项目的架构中,我们一般会同时部署 LVS 和 Nginx 来做 HTTP 应用服务的负载均衡。也就是说,在入口处部署 LVS,将流量分发到多个 Nginx 服务器上,再由 Nginx 服务器分发到应用服务器上, 为什么这么做呢?

主要和 LVS 和 Nginx 的特点有关,LVS 是在网络栈的四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器,所以相比 Nginx,性能会更高,也能够承担更高的并发。

可 LVS 缺陷是工作在四层,而请求的 URL 是七层的概念,不能针对 URL 做更细致地请求分发,而且 LVS 也没有提供探测后端服务是否存活的机制;而 Nginx 虽然比 LVS 的性能差很多,但也可以承担每秒几万次的请求,并且它在配置上更加灵活,还可以感知后端服务是否出现问题。

因此,LVS 适合在入口处,承担大流量的请求分发,而 Nginx 要部署在业务服务器之前做更细维度的请求分发。 我给你的建议是, 如果你的 QPS 在十万以内,那么可以考虑不引入 LVS 而直接使用 Nginx 作为唯一的负载均衡服务器,这样少维护一个组件,也会减少系统的维护成本。

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

客户端负载均衡服务

所以,我们会使用另一类的负载均衡服务,客户端负载均衡服务,也就是把负载均衡的服务内嵌在 RPC 客户端中。

它一般和客户端应用,部署在一个进程中,提供多种选择节点的策略,最终为客户端应用提供一个最佳的,可用的服务端节点。这类服务一般会结合注册中心来使用,注册中心提供服务节点的完整列表,客户端拿到列表之后使用负载均衡服务的策略选取一个合适的节点,然后将请求发到这个节点上。

NAME

了解负载均衡服务的分类,是你学习负载均衡服务的第一步,接下来,你需要掌握负载均衡策略,这样一来,你在实际工作中,配置负载均衡服务的时候,可以对原理有更深刻的了解。

常见的负载均衡策略有哪些

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

  • 一类是静态策略,也就是说负载均衡服务器在选择服务节点时,不会参考后端服务的实际运行的状态。
  • 一类是动态策略,也就是说负载均衡服务器会依据后端服务的一些负载特性,来决定要选择哪一个服务节点。

静态策略

常见的静态策略有几种,其中使用最广泛的是 轮询的策略(RoundRobin,RR), 这种策略会记录上次请求后端服务的地址或者序号,然后在请求时,按照服务列表的顺序,请求下一个后端服务节点。伪代码如下:

AtomicInteger lastCounter = getLastCounter();// 获取上次请求的服务节点的序号 
List<String> serverList = getServerList(); // 获取服务列表
int currentIndex = lastCounter.addAndGet(); // 增加序列号
if(currentIndex >= serverList.size()) {
  currentIndex = 0;
}
setLastCounter(currentIndex);
return serverList.get(currentIndex);

它其实是一种通用的策略,基本上,大部分的负载均衡服务器都支持。轮询的策略可以做到将请求尽量平均地分配到所有服务节点上,但是,它没有考虑服务节点的具体配置情况。比如,你有三个服务节点,其中一个服务节点的配置是 8 核 8G,另外两个节点的配置是 4 核 4G,那么如果使用轮询的方式来平均分配请求的话,8 核 8G 的节点分到的请求数量和 4 核 4G 的一样多,就不能发挥性能上的优势了

所以,我们考虑给节点加上权重值,比如给 8 核 8G 的机器配置权重为 2,那么就会给它分配双倍的流量, 这种策略就是带有权重的轮询策略。

除了这两种策略之外,目前开源的负载均衡服务还提供了很多静态策略:

  • Nginx 提供了 ip_hash 和 url_hash 算法;
  • LVS 提供了按照请求的源地址,和目的地址做 hash 的策略;
  • Dubbo 也提供了随机选取策略,以及一致性 hash 的策略。

但是在我看来, 轮询和带有权重的轮询策略,能够将请求尽量平均地分配到后端服务节点上,也就能够做到对于负载的均衡分配,在没有更好的动态策略之前,应该优先使用这两种策略,比如 Nginx 就会优先使用轮询的策略。

动态策略

而目前开源的负载均衡服务中,也会提供一些动态策略,我强调一下它们的原理。

在负载均衡服务器上会收集对后端服务的调用信息,比如从负载均衡端到后端服务的活跃连接数,或者是调用的响应时间,然后从中选择连接数最少的服务,或者响应时间最短的后端服务。 我举几个具体的例子:

  • Dubbo 提供的 LeastAcive 策略,就是优先选择活跃连接数最少的服务;
  • Spring Cloud 全家桶中的 Ribbon 提供了 WeightedResponseTimeRule 是使用响应时间,给每个服务节点计算一个权重,然后依据这个权重,来给调用方分配服务节点。

这些策略的思考点 是从调用方的角度出发,选择负载最小、资源最空闲的服务来调用,以期望能得到更高的服务调用性能,也就能最大化地使用服务器的空闲资源,请求也会响应地更迅速, 所以,我建议你, 在实际开发中,优先考虑使用动态的策略。

到目前为止,你已经可以根据上面的分析,选择适合自己的负载均衡策略,并选择一个最优的服务节点, 那么问题来了: 你怎么保证选择出来的这个节点,一定是一个可以正常服务的节点呢?如果你采用的是轮询的策略,选择出来的,是一个故障节点又要怎么办呢?所以,为了降低请求被分配到一个故障节点的几率,有些负载均衡服务器,还提供了对服务节点的故障检测功能。

如何检测节点是否故障

24 讲中,我带你了解到,在微服务化架构中,服务节点会定期地向注册中心发送心跳包,这样注册中心就能够知晓服务节点是否故障,也就可以确认传递给负载均衡服务的节点,一定是可用的。

但对于 Nginx 来说, 我们要如何保证配置的服务节点是可用的呢?

这就要感谢淘宝开源的 Nginx 模块 nginx_upstream_check_module 了,这个模块可以让 Nginx 定期地探测后端服务的一个指定的接口,然后根据返回的状态码,来判断服务是否还存活。当探测不存活的次数达到一定阈值时,就自动将这个后端服务从负载均衡服务器中摘除。 它的配置样例如下:

upstream server {
        server 192.168.1.1:8080;
        server 192.168.1.2:8080;
        // 检测间隔为 3 秒,检测超时时间是 1 秒,使用 http 协议。如果连续失败次数达到 5 次就认为服务不可用;如果连续连续成功次数达到 2 次,则认为服务可用。后端服务刚启动时状态是不可用的
        check interval=3000 rise=2 fall=5 timeout=1000 type=http default_down=true;
        check_http_send "GET /health_check HTTP/1.0\r\n\r\n"; // 检测 URL
        check_http_expect_alive http_2xx; // 检测返回状态码为 200 时认为检测成功
}

Nginx 按照上面的方式配置之后,你的业务服务器也要实现一个 /health_check 的接口,在这个接口中返回的 HTTP 状态码,这个返回的状态码可以存储在配置中心中,这样在变更状态码时,就不需要重启服务了(配置中心在第 33 节课中会讲到)。

节点检测的功能,还能够帮助我们实现 Web 服务的优雅关闭。在 24 讲中介绍注册中心时,我曾经提到,服务的优雅关闭需要先切除流量再关闭服务,使用了注册中心之后,就可以先从注册中心中摘除节点,再重启服务,以便达到优雅关闭的目的。那么 Web 服务要如何实现优雅关闭呢?接下来,我来给你了解一下,有了节点检测功能之后,服务是如何启动和关闭的。

在服务刚刚启动时, 可以初始化默认的 HTTP 状态码是 500,这样 Nginx 就不会很快将这个服务节点标记为可用,也就可以等待服务中,依赖的资源初始化完成,避免服务初始启动时的波动。

在完全初始化之后, 再将 HTTP 状态码变更为 200,Nginx 经过两次探测后,就会标记服务为可用。在服务关闭时,也应该先将 HTTP 状态码变更为 500,等待 Nginx 探测将服务标记为不可用后,前端的流量也就不会继续发往这个服务节点。在等待服务正在处理的请求全部处理完毕之后,再对服务做重启,可以避免直接重启导致正在处理的请求失败的问题。 这是启动和关闭线上 Web 服务时的标准姿势,你可以在项目中参考使用。

课程小结

本节课,我带你了解了与负载均衡服务相关的一些知识点,以及在实际工作中的运用技巧。我想强调几个重点:

  • 网站负载均衡服务的部署,是以 LVS 承接入口流量,在应用服务器之前,部署 Nginx 做细化的流量分发,和故障节点检测。当然,如果你的网站的并发不高,也可以考虑不引入 LVS。
  • 负载均衡的策略可以优先选择动态策略,保证请求发送到性能最优的节点上;如果没有合适的动态策略,那么可以选择轮询的策略,让请求平均分配到所有的服务节点上。
  • Nginx 可以引入 nginx_upstream_check_module,对后端服务做定期的存活检测,后端的服务节点在重启时,也要秉承着「先切流量后重启」的原则,尽量减少节点重启对于整体系统的影响。

你可能会认为,像 Nginx、LVS 应该是运维所关心的组件,作为开发人员不用操心维护。 不过通过今天的学习你应该可以看到: 负载均衡服务是提升系统扩展性,和性能的重要组件,在高并发系统设计中,它发挥的作用是无法替代的。理解它的原理,掌握使用它的正确姿势,应该是每一个后端开发同学的必修课。

26 - CH27-分布式-接口网关

到目前为止,你的垂直电商系统在经过微服务化拆分之后,已经运行了一段时间了,系统的扩展性得到了很大的提升,也能够比较平稳地度过高峰期的流量了。

不过最近你发现,随着自己的电商网站知名度越来越高,系统迎来了一些「不速之客」,在凌晨的时候,系统中的搜索商品和用户接口的调用量,会有激剧的上升,持续一段时间之后又回归正常。

这些搜索请求有一个共同特征是,来自固定的几台设备。 当你在搜索服务上加一个针对设备 ID 的限流功能之后,凌晨的高峰搜索请求不见了。但是不久之后,用户服务也出现了大量爬取用户信息的请求,商品接口出现了大量爬取商品信息的请求。你不得不在这两个服务上也增加一样的限流策略。

但是这样会有一个问题: 不同的三个服务上使用同一种策略,在代码上会有冗余,无法做到重用,如果其他服务上也出现类似的问题,还要通过拷贝代码来实现,肯定是不行的。

不过作为 Java 程序员, 你很容易想到: 将限流的功能独立成一个单独的 jar 包,给这三个服务来引用。不过你忽略了一种情况,那就是你的电商团队使用的除了 Java,还有 PHP 和 Golang 等多种语言。

用多种语言开发的服务是没有办法使用 jar 包,来实现限流功能的, 这时你需要引入 API 网关。

API 网关起到的作用

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

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

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

  1. 它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。 在你的系统中,你部署的微服务对外暴露的协议可能不同: 有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址,以及协议的细节,给客户端的调用带来很大的便捷。
  2. 另一方面,在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级,流量控制和分流等等(关于服务降级和流量控制的细节,我会在后面的课程中具体讲解,在这里,你只要知道它们可以在 API 网关中实现就可以了)。
  3. 再有,客户端的认证和授权的实现,也可以放在 API 网关中。你要知道,不同类型的客户端使用的认证方式是不同的。 在我之前项目中, 手机 APP 使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上,可以得到统一处理,应用服务不需要了解认证的细节。
  4. 另外,API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
  5. 最后,在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,我在 25 讲中讲述分布式追踪系统时,提到的标记一次请求的 requestId,也可以在网关中来生成。
NAME

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

NAME

我花一定的篇幅去讲 API 网关起到的作用,主要是想让你了解,API 网关可以解决什么样的实际问题,这样,当你在面对这些问题时,你就会有解决的思路,不会手足无措了。

API 网关要如何实现

了解 API 网关的作用之后,所以接下来,我们来看看 API 网关在实现中需要关注哪些点,以及常见的开源 API 网关有哪些,这样,你在实际工作中,无论是考虑自研 API 网关还是使用开源的实现都会比较自如了。

在实现一个 API 网关时,你首先要考虑的是它的性能 。这很好理解,API 入口网关承担从客户端的所有流量。假如业务服务处理时间是 10ms,而 API 网关的耗时在 1ms,那么相当于每个接口的响应时间都要增加 10%,这对于性能的影响无疑是巨大的。而提升 API 网关性能的关键还是在 I/O 模型上(我在 23 讲中详细讲到过),这里只是举一个例子来说明 I/O 模型对于性能的影响。

Netfix 开源的 API 网关 Zuul,在 1.0 版本的时候使用的是同步阻塞 I/O 模型,整体系统其实就是一个 servlet,在接收到用户的请求,然后执行在网关中配置的认证、协议转换等逻辑之后,调用后端的服务获取数据返回给用户。

而在 Zuul2.0 中,Netfix 团队将 servlet 改造成了一个 netty server(netty 服务),采用 I/O 多路复用的模型处理接入的 I/O 请求,并且将之前同步阻塞调用后端服务的方式,改造成使用 netty client(netty 客户端)非阻塞调用的方式。改造之后,Netfix 团队经过测试发现性能提升了 20% 左右。

除此之外,API 网关中执行的动作有些是可以预先定义好的,比如黑白名单的设置,接口动态路由;有些则是需要业务方依据自身业务来定义。 所以,API 网关的设计要注意扩展性, 也就是你可以随时在网关的执行链路上,增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。

所以一般来说,我们可以把每一个操作定义为一个 filter(过滤器),然后使用「责任链模式」将这些 filter 串起来。责任链可以动态地组织这些 filter,解耦 filter 之间的关系,无论是增加还是减少 filter,都不会对其他的 filter 有任何的影响。

Zuul 就是采用责任链模式, Zuul1 中将 filter 定义为三类:pre routing filter(路由前过滤器)、routing filter(路由过滤器)和 after routing filter(路由后过滤器)。每一个 filter 定义了执行的顺序,在 filter 注册时,会按照顺序插入到 filter chain(过滤器链)中。这样 Zuul 在接收到请求时,就会按照顺序依次执行插入到 filter chain 中的 filter 了。

NAME

另外还需要注意的一点是, 为了提升网关对于请求的并行处理能力,我们一般会使用线程池来并行的执行请求。 不过,这就带来一个问题: 如果商品服务出现问题,造成响应缓慢,那么调用商品服务的线程就会被阻塞无法释放,久而久之,线程池中的线程就会被商品服务所占据,那么其他服务也会受到级联的影响。因此,我们需要针对不同的服务做线程隔离,或者保护。 在我看来有两种思路:

  • 如果你后端的服务拆分得不多,可以针对不同的服务,采用不同的线程池,这样商品服务的故障就不会影响到支付服务和用户服务了;
  • 在线程池内部可以针对不同的服务,甚至不同的接口做线程的保护。比如说,线程池的最大线程数是 1000,那么可以给每个服务设置一个最多可以使用的配额。

一般来说,服务的执行时间应该在毫秒级别,线程被使用后会很快被释放,回到线程池给后续请求使用,同时处于执行中的线程数量不会很多,对服务或者接口设置线程的配额,不会影响到正常的执行。可是一旦发生故障,某个接口或者服务的响应时间变长,造成线程数暴涨,但是因为有配额的限制,也就不会影响到其他的接口或者服务了。

你在实际应用中也可以将这两种方式结合, 比如说针对不同的服务使用不同的线程池,在线程池内部针对不同的接口设置配额。

以上就是实现 API 网关的一些关键的点,你如果要自研 API 网关服务的话可以参考借鉴。另外 API 网关也有很多开源的实现,目前使用比较广泛的有以下几个:

  • Kong 是在 Nginx 中运行的 Lua 程序。得益于 Nginx 的性能优势,Kong 相比于其它的开源 API 网关来说,性能方面是最好的。由于大中型公司对于 Nginx 运维能力都比较强,所以选择 Kong 作为 API 网关,无论是在性能还是在运维的把控力上,都是比较好的选择;
  • Zuul 是 Spring Cloud 全家桶中的成员,如果你已经使用了 Spring Cloud 中的其他组件,那么也可以考虑使用 Zuul 和它们无缝集成。不过,Zuul1 因为采用同步阻塞模型,所以在性能上并不是很高效,而 Zuul2 推出时间不长,难免会有坑。但是 Zuul 的代码简单易懂,可以很好的把控,并且你的系统的量级很可能达不到 Netfix 这样的级别,所以对于 Java 技术栈的团队,使用 Zuul 也是一个不错的选择;
  • Tyk 是一种 Go 语言实现的轻量级 API 网关,有着丰富的插件资源,对于 Go 语言栈的团队来说,也是一种不错的选择。

那么你要考虑的是, 这些开源项目适不适合作为 AIP 网关供自己使用。而接下来,我以电商系统为例,带你将 API 网关引入我们的系统之中。

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

目前为止,我们的电商系统已经经过了服务化改造,在服务层和客户端之间有一层薄薄的 Web 层, 这个 Web 层做的事情主要有两方面:

一方面是对服务层接口数据的聚合。比如,商品详情页的接口,可能会聚合服务层中,获取商品信息、用户信息、店铺信息以及用户评论等多个服务接口的数据;

另一方面 Web 层还需要将 HTTP 请求转换为 RPC 请求,并且对前端的流量做一些限制,对于某些请求添加设备 ID 的黑名单等等。

因此,我们在做改造的时候,可以先将 API 网关从 Web 层中独立出来,将协议转换、限流、黑白名单等事情,挪到 API 网关中来处理,形成独立的入口网关层;

而针对服务接口数据聚合的操作, 一般有两种解决思路:

  1. 再独立出一组网关专门做服务聚合、超时控制方面的事情,我们一般把前一种网关叫 做流量网关,后一种可以叫做业务网关;
  2. 抽取独立的服务层,专门做接口聚合的操作。这样服务层就大概分为 原子服务层聚合服务层

我认为,接口数据聚合是业务操作,与其放在通用的网关层来实现,不如放在更贴近业务的服务层来实现, 所以,我更倾向于第二种方案。

NAME

同时,我们可以在系统和第三方支付服务,以及登陆服务之间部署出口网关服务。原先,你会在拆分出来的支付服务中,完成对于第三方支付接口所需要数据的加密、签名等操作,再调用第三方支付接口,完成支付请求。现在,你把对数据的加密、签名的操作放在出口网关中,这样一来,支付服务只需要调用出口网关的统一支付接口就可以了。

在引入了 API 网关之后,我们的系统架构就变成了下面这样:

NAME

课程小结

本节课我带你了解了 API 网关在系统中的作用,在实现中的一些关键的点,以及如何将 API 网关引入你的系统, 我想强调的重点如下:

  1. API 网关分为入口网关和出口网关两类,入口网关作用很多,可以隔离客户端和微服务,从中提供协议转换、安全策略、认证、限流、熔断等功能。出口网关主要是为调用第三方服务提供统一的出口,在其中可以对调用外部的 API 做统一的认证、授权,审计以及访问控制;
  2. API 网关的实现重点在于性能和扩展性,你可以使用多路 I/O 复用模型和线程池并发处理,来提升网关性能,使用责任链模式来提升网关的扩展性;
  3. API 网关中的线程池,可以针对不同的接口或者服务做隔离和保护,这样可以提升网关的可用性;
  4. API 网关可以替代原本系统中的 Web 层,将 Web 层中的协议转换、认证、限流等功能挪入到 API 网关中,将服务聚合的逻辑下沉到服务层。

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

27 - CH28-分布式-跨地域

来想象这样一个场景: 你的垂直电商系统部署的 IDC 机房,在某一天发布了公告说,机房会在第二天凌晨做一次网络设备的割接,在割接过程中会不定时出现瞬间,或短时间网络中断。

机房网络的中断,肯定会对业务造成不利的影响,即使割接的时间在凌晨(业务的低峰期),作为技术负责人的你,也要尽量思考方案来规避隔离的影响。然而不幸的是,在现有的技术架构下,电商业务全都部署在一个 IDC 机房中,你并没有好的解决办法。

而 IDC 机房的可用性问题是整个系统的 阿喀琉斯之踵,一旦 IDC 机房像一些大厂一样,出现很严重的问题,就会对整体服务的可用性造成严重的影响。比如:

2016 年 7 月,北京联通整顿旗下 40 多个 IDC 机房中,不规范的接入情况,大批不合规接入均被断网,这一举动致使脉脉当时使用的蓝汛机房受到影响,脉脉宕机长达 15 个小时,著名的 A 站甚至宕机超过 48 个小时,损失可想而知。

而目前,单一机房部署的架构特点,决定了你的系统可用性受制于机房的可用性,也就是机房掌控了系统的生命线。所以,你开始思考,如何通过架构的改造,来进一步提升系统的可用性。在网上搜索解决方案和学习一些大厂的经验后,你发现「多机房部署」可以解决这个问题。

多机房部署的难点是什么

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

这样,当其中某一个机房出现网络故障、火灾,甚至整个城市发生地震、洪水等大的不可抗的灾难时,你可以随时将用户的流量切换到其它地域的机房中,从而保证系统可以不间断地持续运行。这种架构听起来非常美好,但是在实现上却是非常复杂和困难的,那么它复杂在哪儿呢?

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

一个思路是直接跨机房读取 A 机房的从库:

NAME

另一个思路是在机房 B 部署一个从库,跨机房同步主库的数据,然后机房 B 的应用就可以读取这个从库的数据了:

NAME

无论是哪一种思路, 都涉及到跨机房的数据传输, 这就对机房之间延迟情况有比较高的要求了。而机房之间的延迟,和机房之间的距离息息相关, 你可以记住几个数字:

  1. 北京同地双机房之间的专线延迟一般在 1ms~3ms。

    这个延迟会造成怎样的影响呢? 要知道,我们的接口响应时间需要控制在 200ms 之内,而一个接口可能会调用几次第三方 HTTP 服务,或者 RPC 服务。如果这些服务部署在异地机房,那么接口响应时间就会增加几毫秒,是可以接受的。

    一次接口可能会涉及几次的数据库写入,那么如果数据库主库在异地机房,那么接口的响应时间也会因为写入异地机房的主库,增加几毫秒到十几毫秒,也是可以接受的。

    但是,接口读取缓存和数据库的数量,可能会达到十几次甚至几十次,那么这就会增加几十毫秒甚至上百毫秒的延迟,就不能接受了。

  2. 国内异地双机房之间的专线延迟会在 50ms 之内。

    具体的延迟数据依据距离的不同而不同。比如,北京到天津的专线延迟,会在 10ms 之内;而北京到上海的延迟就会提高到接近 30ms;如果想要在北京和广州部署双机房,那么延迟就会到达 50ms 了。 在这个延迟数据下, 要想保证接口的响应时间在 200ms 之内,就要尽量减少跨机房的服务调用,更要避免跨机房的数据库和缓存操作了。

  3. 如果你的业务是国际化的服务,需要部署跨国的双机房,那么机房之间的延迟就更高了,依据各大云厂商的数据来看,比如,从国内想要访问部署在美国西海岸的服务,这个延迟会在 100ms~200ms 左右。在这个延迟下,就要避免数据跨机房同步调用,而只做异步的数据同步。

如果你正在考虑多机房部署的架构,那么这些数字都是至关重要的基础数据, 你需要牢牢记住, 避免出现跨机房访问数据造成性能衰减问题。

机房之间的数据延迟,在客观上是存在的,你没有办法改变,你可以做的,就是尽量避免数据延迟对于接口响应时间的影响。那么在数据延迟下, 你要如何设计多机房部署的方案呢?

逐步迭代多机房部署方案

1. 同城双活

制定多机房部署的方案不是一蹴而就的,而是不断迭代发展的。我在上面提到,同城机房之间的延时在 1ms~3ms 左右,对于跨机房调用的容忍度比较高,所以,这种同城双活的方案复杂度会比较低。

但是,它只能做到机房级别的容灾,无法做到城市级别的容灾。不过,相比于城市发生地震、洪水等自然灾害来说,机房网络故障、掉电出现的概率要大的多。所以,如果你的系统不需要考虑城市级别的容灾,一般做到同城双活就足够了。 那么,同城双活的方案要如何设计呢?

假设这样的场景: 你在北京有 A 和 B 两个机房,A 是联通的机房,B 是电信的机房,机房之间以专线连接,方案设计时,核心思想是,尽量避免跨机房的调用。 具体方案如下:

  • 首先,数据库的主库可以部署在一个机房中,比如部署在 A 机房中,那么 A 和 B 机房数据都会被写入到 A 机房中。然后,在 A、B 两个机房中各部署一个从库,通过主从复制的方式,从主库中同步数据,这样双机房的查询请求可以查询本机房的从库。一旦 A 机房发生故障,可以通过主从切换的方式,将 B 机房的从库提升为主库,达到容灾的目的。
  • 缓存也可以部署在两个机房中,查询请求也读取本机房的缓存,如果缓存中数据不存在,就穿透到本机房的从库中,加载数据。数据的更新可以更新双机房中的数据,保证数据的一致性。
  • 不同机房的 RPC 服务会向注册中心,注册不同的服务组,而不同机房的 RPC 客户端,也就是 Web 服务,只订阅同机房的 RPC 服务组,这样就可以实现 RPC 调用尽量发生在本机房内,避免跨机房的 RPC 调用。
NAME

你的系统肯定会依赖公司内的其他服务,比如审核,搜索等服务,如果这些服务也是双机房部署的,那么也需要尽量保证只调用本机房的服务,降低调用的延迟。

使用了同城双活架构之后,可以实现机房级别的容灾,服务的部署也能够突破单一机房的限制,但是,还是会存在跨机房写数据的问题,不过鉴于写数据的请求量不高,所以在性能上是可以容忍的。

2. 异地多活

上面这个方案,足够应对你目前的需要,但是,你的业务是不断发展的,如果有朝一日,你的电商系统的流量达到了京东或者淘宝的级别,那么你就要考虑,即使机房所在的城市发生重大的自然灾害,也要保证系统的可用性。 而这时,你需要采用异地多活的方案 (据我所知,阿里和饿了么采用的都是异地多活的方案)。

在考虑异地多活方案时,你首先要考虑异地机房的部署位置。它部署的不能太近,否则发生自然灾害时,很可能会波及。所以,如果你的主机房在北京,那么异地机房就尽量不要建设在天津,而是可以选择上海、广州这样距离较远的位置。但这就会造成更高的数据传输延迟,同城双活中,使用的跨机房写数据库的方案,就不合适了。

所以,在数据写入时,你要保证只写本机房的数据存储服务,再采取数据同步的方案,将数据同步到异地机房中。 一般来说,数据同步的方案有两种:

  • 一种基于存储系统的主从复制,比如 MySQL 和 Redis。也就是在一个机房部署主库,在异地机房部署从库,两者同步主从复制, 实现数据的同步。
  • 另一种是基于消息队列的方式。一个机房产生写入请求后,会写一条消息到消息队列,另一个机房的应用消费这条消息后,再执行业务处理逻辑,写入到存储服务中。

我建议你, 采用两种同步相结合的方式,比如,你可以基于消息的方式,同步缓存的数据、HBase 数据等。然后基于存储,主从复制同步 MySQL、Redis 等数据。

无论是采取哪种方案,数据从一个机房,传输到另一个机房都会有延迟,所以,你需要尽量保证用户在读取自己的数据时,读取数据主库所在的机房。为了达到这一点,你需要对用户做分片,让一个用户每次的读写都尽量在同一个机房中。同时,在数据读取和服务调用时,也要尽量调用本机房的服务。 这里有一个场景: 假如在电商系统中,用户 A 要查看所有订单的信息,而这些订单中,店铺的信息和卖家的信息很可能是存储在异地机房中,那么你应该优先保证服务调用,和数据读取在本机房中进行,即使读取的是跨机房从库的数据,会有一些延迟,也是可以接受的。

NAME

课程小结

本节课,为了提升系统的可用性和稳定性,我带你探讨了多机房部署的难点,以及同城双机房和异地多活的部署架构, 在这里,我想强调几个重点:

  • 不同机房的数据传输延迟,是造成多机房部署困难的主要原因,你需要知道,同城多机房的延迟一般在 1ms~3ms,异地机房的延迟在 50ms 以下,而跨国机房的延迟在 200ms 以下。
  • 同城多机房方案可以允许有跨机房数据写入的发生,但是数据的读取,和服务的调用应该尽量保证在同一个机房中。
  • 异地多活方案则应该避免跨机房同步的数据写入和读取,而是采取异步的方式,将数据从一个机房同步到另一个机房。

多机房部署是一个业务发展到一定规模,对于机房容灾有需求时,才会考虑的方案,能不做则尽量不要做。一旦你的团队决定做多机房部署,那么同城双活已经能够满足你的需求了,这个方案相比异地多活要简单很多。而在业界,很少有公司,能够搭建一套真正的异步多活架构,这是因为这套架构在实现时过于复杂, 所以,轻易不要尝试。

总之,架构需要依据系统的量级和对可用性、性能、扩展性的要求,不断演进和调整,盲目地追求架构的 「先进性」只能造成方案的复杂,增加运维成本,从而给你的系统维护带来不便。

28 - CH29-分布式-服务网格

在分布式服务篇的前几节课程中,我带你了解了在微服务化过程中,要使用哪些中间件解决服务之间通信和服务治理的问题,其中就包括:

  • 用 RPC 框架解决服务通信的问题;
  • 用注册中心解决服务注册,和发现的问题;
  • 使用分布式 Trace 中间件,排查跨服务调用慢请求;
  • 使用负载均衡服务器,解决服务扩展性的问题;
  • 在 API 网关中植入服务熔断、降级和流控等服务治理的策略。

经历了这几环之后,你的垂直电商系统基本上,已经完成了微服务化拆分的改造。不过,目前来看,你的系统使用的语言还是以 Java 为主,之前提到的服务治理的策略,和服务之间通信协议也是使用 Java 语言来实现的。

那么这会存在一个问题: 一旦你的团队中,有若干个小团队开始尝试使用 Go 或者 PHP,来开发新的微服务,那么在微服务化过程中,一定会受到挑战。

跨语言体系带来的挑战

其实,一个公司的不同团队,使用不同的开发语言是比较常见的。比如,微博的主要开发语言是 Java 和 PHP,近几年也有一些使用 Go 开发的系统。而使用不同的语言开发出来的微服务, 在相互调用时会存在两方面的挑战:

一方面,服务之间的通信协议上,要对多语言友好,要想实现跨语言调用,关键点是选择合适的序列化方式。 我给你举一个例子。

比如,你用 Java 开发一个 RPC 服务,使用的是 Java 原生的序列化方式,这种序列化方式对于其它语言并不友好,那么,你使用其它语言,调用这个 RPC 服务时,就很难解析序列化之后的二进制流。 所以,我建议你, 在选择序列化协议时,考虑序列化协议是否对多语言友好,比如,你可以选择 Protobuf、Thrift,这样一来,跨语言服务调用的问题,就可以很容易地解决了。

另一方面,使用新语言开发的微服务,无法使用之前积累的,服务治理的策略。比如说,RPC 客户端在使用注册中心,订阅服务的时候,为了避免每次 RPC 调用都要与注册中心交互,一般会在 RPC 客户端,缓存节点的数据。如果注册中心中的服务节点发生了变更,那么 RPC 客户端的节点缓存会得到通知,并且变更缓存数据。

而且,为了减少注册中心的访问压力,在 RPC 客户端上,我们一般会考虑使用多级缓存(内存缓存和文件缓存)来保证节点缓存的可用性。而这些策略在开始的时候,都是使用 Java 语言来实现的,并且封装在注册中心客户端里,提供给 RPC 客户端使用。如果更换了新的语言,这些逻辑就都要使用新的语言实现一套。

除此之外,负载均衡、熔断降级、流量控制、打印分布式追踪日志等等,这些服务治理的策略都需要重新实现,而使用其它语言重新实现这些策略无疑会带来巨大的工作量,也是中间件研发中,一个很大的痛点。

那么,你要如何屏蔽服务化架构中,服务治理的细节,或者说, 如何让服务治理的策略在多语言之间复用呢?

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

Service Mesh 是如何工作的

1. 什么是 Service Mesh

Service Mesh 主要处理服务之间的通信,它的主要实现形式就是在应用程序同主机上部署一个代理程序,一般来讲,我们将这个代理程序称为 Sidecar(边车),服务之间的通信也从之前的,客户端和服务端直连,变成了下面这种形式:

NAME

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

这种方式,可以把业务代码和服务治理的策略隔离开,将服务治理策略下沉,让它成为独立的基础模块。这样一来,不仅可以实现跨语言,服务治理策略的复用,还能对这些 Sidecar 做统一的管理。

目前,业界提及最多的 Service Mesh 方案当属 istio ,它的玩法是这样的:

NAME

它将组件分为 数据平面控制平面

  • 数据平面就是我提到的 Sidecar(Istio 使用 Envoy 作为 Sidecar 的实现)。
  • 控制平面主要负责服务治理策略的执行,在 Istio 中,主要分为 Mixer、Pilot 和 Istio-auth 三部分。

你可以先不了解每一部分的作用,只知道它们共同构成了服务治理体系就可以了。

然而,在 Istio 中,每次请求都需要经过控制平面,也就是说,每次请求都需要跨网络的调用 Mixer,这会极大地影响性能。

因此,国内大厂开源出来的 Service Mesh 方案中,一般只借鉴 Istio 的数据平面和控制平面的思路,然后将服务治理策略做到了 Sidecar 中,控制平面只负责策略的下发,这样就不需要每次请求都经过控制平面,性能上会改善很多。

2. 如何将流量转发到 Sidecar 中

在 Service Mesh 的实现中,一个主要的问题,是如何尽量无感知地引入 Sidecar 作为网络代理,也就是说,无论是数据流入还是数据流出时,都要将数据包重定向到 Sidecar 的端口上。 实现思路一般有两个:

第一种,使用 iptables 的方式来实现流量透明的转发 ,而 Istio 就默认了,使用 iptables 来实现数据包的转发。为了能更清晰的说明流量转发的原理,我们先简单地回顾一下什么是 iptables。

Iptables 是 Linux 内核中,防火墙软件 Netfilter 的管理工具,它位于用户空间,可以控制 Netfilter,实现地址转换的功能。在 iptables 中默认有五条链,你可以把这五条链,当作数据包流转过程中的五个步骤,依次为 PREROUTING,INPUT,FORWARD,OUTPUT 和 POSTROUTING。数据包传输的大体流程如下:

NAME

从图中可以看到,数据包以 PREROUTING 链作为入口,当数据包目的地为本机时,它们也都会流经到 OUTPUT 链。所以,我们可以在这两个链上,增加一些规则,将数据包重定向。我以 Istio 为例,带你看看如何使用 iptables 实现流量转发。

在 Istio 中,有一个叫做 istio-iptables.sh 的脚本,这个脚本在 Sidecar 被初始化的时候执行,主要是设置一些 iptables 规则。

我摘录了一些关键点来说明一下:

# 流出流量处理
iptables -t nat -N ISTIO_REDIRECT   # 增加 ISTIO_REDIRECT 链处理流出流量
iptables -t nat -A ISTIO_REDIRECT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" # 重定向流量到 Sidecar 的端口上
iptables -t nat -N ISTIO_OUTPUT # 增加 ISTIO_OUTPUT 链处理流出流量
iptables -t nat -A OUTPUT -p tcp -j ISTIO_OUTPUT # 将 OUTPUT 链的流量重定向到 ISTIO_OUTPUT 链上
for uid in ${PROXY_UID}; do
	# Sidecar 本身的流量不转发
    iptables -t nat -A ISTIO_OUTPUT -m owner --uid-owner "${uid}" -j RETURN 
done
for gid in ${PROXY_GID}; do
	# Sidecar 本身的流量不转发
    iptables -t nat -A ISTIO_OUTPUT -m owner --gid-owner "${gid}" -j RETURN  
done
iptables -t nat -A ISTIO_OUTPUT -j ISTIO_REDIRECT # 将 ISTIO_OUTPUT 链的流量转发到 ISTIO_REDIRECT

# 流入流量处理
iptables -t nat -N ISTIO_IN_REDIRECT  # 增加 ISTIO_IN_REDIRECT 链处理流入流量
iptables -t nat -A ISTIO_IN_REDIRECT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" # 将流入流量重定向到 Sidecar 端口
iptables -t ${table} -N ISTIO_INBOUND # 增加 ISTIO_INBOUND 链处理流入流量
iptables -t ${table} -A PREROUTING -p tcp -j ISTIO_INBOUND # 将 PREROUTING 的流量重定向到 ISTIO_INBOUND 链
iptables -t nat -A ISTIO_INBOUND -p tcp --dport "${port}" -j ISTIO_IN_REDIRECT # 将 ISTIO_INBOUND 链上指定目的端口的流量重定向到 ISTIO_IN_REDIR

假设服务的节点部署在 9080 端口上,Sidecar 开发的端口是 15001,那么流入流量的流向如下:

NAME

Iptables 方式的优势在于, 对于业务完全透明,业务甚至不知道有 Sidecar 存在,这样会减少业务接入的时间。不过,它也有缺陷,那就是它是在高并发下,性能上会有损耗,因此国内大厂采用了另外一种方式: 轻量级客户端。

在这种方式下,RPC 客户端会通过配置的方式,知道 Sidecar 的部署端口,然后通过一个轻量级客户端,将调用服务的请求发送给 Sidecar,Sidecar 在转发请求之前,先执行一些服务治理的策略,比如说,从注册中心中,查询到服务节点信息并且缓存起来,然后从服务节点中,使用某种负载均衡的策略选出一个节点等等。

请求被发送到服务端的 Sidecar 上后,然后在服务端记录访问日志,和分布式追踪日志,再把请求转发到真正的服务节点上。当然,服务节点在启动时,会委托服务端 Sidecar,向注册中心注册节点,Sidecar 也就知道了真正服务节点部署的端口是多少。整个请求过程如图所示:

NAME

当然,除了 iptables 和轻量级客户端两种方式外,目前在探索的方案还有 Cilium,这个方案可以从 Socket 层面实现请求的转发,也就可以避免 iptables 方式在性能上的损耗。 在这几种方案中,我建议你使用轻量级客户端的方式, 这样虽然会有一些改造成本,但是却在实现上最简单,可以快速的让 Service Mesh 在你的项目中落地。

当然,无论采用哪种方式,你都可以实现将 Sidecar 部署到,客户端和服务端的调用链路上,让它代理进出流量,这样,你就可以使用运行在 Sidecar 中的服务治理的策略了。至于这些策略我在前面的课程中都带你了解过(你可以回顾 23 至 26 讲的课程),这里就不再赘述了。

与此同时,我也建议你了解目前业界一些开源的 Service Mesh 框架,这样在选择方案时可以多一些选择。目前在开源领域比较成熟的 Service Mesh 框架有下面几个,你可以通过阅读它们的文档来深入了解,作为本节课的引申阅读。

  • Istio 这个框架在业界最为著名,它提出了数据平面和控制平面的概念,是 Service Mesh 的先驱,缺陷就是刚才提到的 Mixer 的性能问题。
  • Linkerd 是第一代的 Service Mesh,使用 Scala 语言编写,其劣势就是内存的占用。
  • SOFAMesh 是蚂蚁金服开源的 Service Mesh 组件,在蚂蚁金服已经有大规模落地的经验。

课程小结

本节课,为了解决跨语言场景下,服务治理策略的复用问题,我带你了解了什么是 Service Mesh 以及如何在实际项目中落地,你需要的重点内容如下:

  1. Service Mesh 分为数据平面和控制平面。数据平面主要负责数据的传输;控制平面用来控制服务治理策略的植入。出于性能的考虑,一般会把服务治理策略植入到数据平面中,控制平面负责服务治理策略数据的下发。
  2. Sidecar 的植入方式目前主要有两种实现方式,一种是使用 iptables 实现流量的劫持;另一种是通过轻量级客户端来实现流量转发。

目前,在一些大厂中,比如微博、蚂蚁金服,Service Mesh 已经开始在实际项目中大量的落地实践,而我建议你持续关注这项技术。它本身是一种将业务与通信基础设施分离的技术,如果你的业务上遇到多语言环境下,服务治理的困境,如果你的遗留服务,需要快速植入服务治理策略,如果你想要将你在服务治理方面积累的经验,快速地与其他团队共享,那么 Service Mesh 就是你的一个不错的选择。

29 - CH30-维护-后端监控

在一个项目的生命周期里,运行维护占据着很大的比重,在重要性上,它几乎与项目研发并驾齐驱。而在系统运维过程中,能够及时地发现问题并解决问题,是每一个团队的本职工作。所以,你的垂直电商系统在搭建之初,运维团队肯定完成了对于机器 CPU、内存、磁盘、网络等基础监控,期望能在出现问题时,及时地发现并且处理。你本以为万事大吉,却没想到系统在运行过程中,频频得到用户的投诉,原因是:

  • 使用的数据库主从延迟变长,导致业务功能上出现了问题;
  • 接口的响应时间变长,用户反馈商品页面出现空白页;
  • 系统中出现大量错误,影响了用户的正常使用。

这些问题,你本应该及时发现并处理的。但现实是,你只能被动地在问题被用户反馈后,手忙脚乱地修复。这时,你的团队才意识到,要想快速地发现和定位业务系统中出现的问题,必须搭建一套完善的服务端监控体系。正所谓「道路千万条,监控第一条,监控不到位,领导两行泪」。不过,在搭建的过程中,你的团队又陷入了困境:

  • 首先,监控的指标要如何选择呢?
  • 采集这些指标可以有哪些方法和途径呢?
  • 指标采集到之后又要如何处理和展示呢?

这些问题,一环扣一环,关乎着系统的稳定性和可用性,而本节课,我就带你解决这些问题,搭建一套服务端监控体系。

监控指标如何选择

你在搭建监控系统时,所面临的第一个问题就是,选择什么样的监控指标,也就是监控什么。有些同学在给一个新的系统,设定监控指标的时候,会比较迷茫,不知道从哪方面入手。其实,有一些成熟的理论和套路,你可以直接拿来使用。比如,谷歌针对分布式系统监控的经验总结,四个黄金信号(Four Golden Signals)。它指的是,在服务层面一般需要监控四个指标, 分别是延迟,通信量、错误和饱和度。

  • 延迟指的是请求的响应时间。比如,接口的响应时间、访问数据库和缓存的响应时间。
  • 通信量可以理解为吞吐量,也就是单位时间内,请求量的大小。比如,访问第三方服务的请求量,访问消息队列的请求量。
  • 错误表示当前系统发生的错误数量。这里需要注意的是, 我们需要监控的错误既有显示的,比如在监控 Web 服务时,出现 4xx5xx 的响应码;也有隐示的,比如,Web 服务虽然返回的响应码是 200,但是却发生了一些和业务相关的错误(出现了数组越界的异常或者空指针异常等),这些都是错误的范畴。
  • 饱和度指的是服务或者资源到达上限的程度(也可以说是服务或者资源的利用率),比如说 CPU 的使用率,内存使用率,磁盘使用率,缓存数据库的连接数等等。

这四个黄金信号提供了通用的监控指标,除此之外,你还可以借鉴 RED 指标体系。 这个体系,是四个黄金信号中衍生出来的,其中,R 代表请求量(Request rate),E 代表错误(Error),D 代表响应时间(Duration),少了饱和度的指标。你可以把它当作一种简化版的通用监控指标体系。

当然,一些组件或者服务还有独特的指标,这些指标也是需要你特殊关注的。比如,课程中提到的数据库主从延迟数据、消息队列的堆积情况、缓存的命中率等等。我把高并发系统中常见组件的监控指标,整理成了一张表格,其中没有包含诸如 CPU、内存、网络、磁盘等基础监控指标,只是业务上监控指标,主要方便你在实际工作中参考使用。

NAME

选择好了监控指标之后,你接下来要考虑的,是如何从组件或者服务中,采集到这些指标,也就是指标数据采集的问题。

如何采集数据指标

说到监控指标的采集,我们一般会依据采集数据源的不同,选用不同的采集方式,总结起来,大概有以下几种类型:

首先, Agent 是一种比较常见的,采集数据指标的方式。

我们通过在数据源的服务器上,部署自研或者开源的 Agent,来收集收据,发送给监控系统,实现数据的采集。在采集数据源上的信息时,Agent 会依据数据源上,提供的一些接口获取数据,我给你举两个典型的例子。

比如,你要从 Memcached 服务器上,获取它的性能数据,那么,你就可以在 Agent 中,连接这个 Memcached 服务器,并且发送一个 stats 命令,获取服务器的统计信息。然后,你就可以从返回的信息中,挑选重要的监控指标,发送给监控服务器,形成 Memcached 服务的监控报表。你也可以从这些统计信息中,看出当前 Memcached 服务器,是否存在潜在的问题。下面是我推荐的,一些重要的状态项,你可以参考使用。

STAT cmd_get 201809037423    // 计算查询的 QPS
STAT cmd_set 16174920166     // 计算写入的 QPS
STAT get_hits 175226700643   // 用来计算命中率,命中率 = get_hits/cmd_get
STAT curr_connections 1416   // 当前连接数
STAT bytes 3738857307        // 当前内存占用量
STAT evictions 11008640149   // 当前被 memcached 服务器剔除的 item 数量,如果这个数量过大 (比如例子中的这个数值),那么代表当前 Memcached 容量不足或者 Memcached Slab Class 分配有问题

另外,如果你是 Java 的开发者,那么一般使用 Java 语言开发的中间件,或者组件,都可以通过 JMX 获取统计或者监控信息。比如,在 19 讲中,我提到可以使用 JMX,监控 Kafka 队列的堆积数,再比如,你也可以通过 JMX 监控 JVM 内存信息和 GC 相关的信息。

另一种很重要的数据获取方式,是在代码中埋点。

这个方式与 Agent 的不同之处在于,Agent 主要收集的是组件服务端的信息,而埋点则是从客户端的角度,来描述所使用的组件,和服务的性能和可用性。那么埋点的方式怎么选择呢?

你可以使用 25 讲分布式 Trace 组件中,提到的面向切面编程的方式;也可以在资源客户端中,直接计算调用资源或者服务的耗时、调用量、慢请求数,并且发送给监控服务器。

这里你需要注意一点, 由于调用缓存、数据库的请求量会比较高,一般会单机也会达到每秒万次,如果不经过任何优化,把每次请求耗时都发送给监控服务器,那么,监控服务器会不堪重负。所以,我们一般会在埋点时,先做一些汇总。比如,每隔 10 秒汇总这 10 秒内,对同一个资源的请求量总和、响应时间分位值、错误数等,然后发送给监控服务器。这样,就可以大大减少发往监控服务器的请求量了。

最后, 日志也是你监控数据的重要来源之一。

你所熟知的 Tomcat 和 Nginx 的访问日志,都是重要的监控日志。你可以通过开源的日志采集工具,将这些日志中的数据发送给监控服务器。目前,常用的日志采集工具有很多,比如,Apache Flume、Fluentd 和 Filebeat,你可以选择一种熟悉的使用。比如在我的项目中,我会倾向于使用 Filebeat 来收集监控日志数据。

监控数据的处理和存储

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

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

一个处理程序接收到数据后,把数据写入到 Elasticsearch,然后通过 Kibana 展示数据,这份数据主要是用来做原始数据的查询;

另一个处理程序是一些流式处理的中间件,比如,Spark、Storm。它们从消息队列里,接收数据后会做一些处理,这些处理包括:

  • 解析数据格式,尤其是日志格式 从里面提取诸如请求量、响应时间、请求 URL 等数据;

  • 对数据做一些聚合运算

    比如,针对 Tomcat 访问日志,可以计算同一个 URL 一段时间之内的请求量、响应时间分位值、非 200 请求量的大小等等。

  • 将数据存储在时间序列数据库中

    这类数据库的特点是,可以对带有时间标签的数据,做更有效的存储,而我们的监控数据恰恰带有时间标签,并且按照时间递增,非常适合存储在时间序列数据库中。目前业界比较常用的时序数据库有 InfluxDB、OpenTSDB、Graphite,各大厂的选择均有不同,你可以选择一种熟悉的来使用。

最后, 你就可以通过 Grafana 来连接时序数据库,将监控数据绘制成报表,呈现给开发和运维的同学了。

NAME

至此,你和你的团队,也就完成了垂直电商系统,服务端监控系统搭建的全过程。这里我想再多说一点,我们从不同的数据源中采集了很多的指标,最终在监控系统中一般会形成以下几个报表,你在实际的工作中可以参考借鉴:

  1. 访问趋势报表

    这类报表接入的是 Web 服务器,和应用服务器的访问日志,展示了服务整体的访问量、响应时间情况、错误数量、带宽等信息。它主要反映的是,服务的整体运行情况,帮助你来发现问题。

  2. 性能报表

    这类报表对接的是资源和依赖服务的埋点数据,展示了被埋点资源的访问量和响应时间情况。它反映了资源的整体运行情况,当你从访问趋势报表发现问题后,可以先从性能报表中,找到究竟是哪一个资源或者服务出现了问题。

  3. 资源报表

    这类报表主要对接的是,使用 Agent 采集的,资源的运行情况数据。当你从性能报表中,发现某一个资源出现了问题,那么就可以进一步从这个报表中,发现资源究竟出现了什么问题,是连接数异常增高,还是缓存命中率下降。这样可以进一步帮你分析问题的根源,找到解决问题的方案。

课程小结

本节课,我带你了解了,服务端监控搭建的过程,在这里,你需要了解以下几个重点:

  1. 耗时、请求量和错误数是三种最通用的监控指标,不同的组件还有一些特殊的监控指标,你在搭建自己的监控系统的时候可以直接使用;
  2. Agent、埋点和日志是三种最常见的数据采集方式;
  3. 访问趋势报表用来展示服务的整体运行情况,性能报表用来分析资源或者依赖的服务是否出现问题,资源报表用来追查资源问题的根本原因。这三个报表共同构成了你的服务端监控体系。

总之,监控系统是你发现问题,排查问题的重要工具,你应该重视它,并且投入足够的精力来不断地完善它。只有这样,才能不断地提高对系统运维的掌控力,降低故障发生的风险。

30 - CH31-维护-应用监控

上一节课中,我带你了解了服务端监控搭建的过程。有了监控报表之后,你的团队在维护垂直电商系统时,就可以更早地发现问题,也有直观的工具辅助你们分析排查问题了。

不过,你很快发现,有一些问题,服务端的监控报表无法排查,甚至无法感知。比如,有用户反馈创建订单失败,但是从服务端的报表来看,并没有什么明显的性能波动,从存储在 Elasticsearch 里的原始日志中,甚至没有找到这次创建订单的请求。这有可能是客户端有 Bug,或者网络抖动导致创建订单的请求并没有发送到服务端。

再比如,有些用户会反馈,使用长城宽带打开商品详情页面特别慢,甚至出现 DNS 解析失败的情况。那么,当我们遇到这类问题时,要如何排查和优化呢?

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

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

如果你能做到这一点,那么无论是订单创建问题的排查,还是长城宽带用户页面打开缓慢的问题,都可以通过这套监控来发现和排查。 那么,如何搭建这么一套端到端的监控体系呢?

如何搭建 APM 系统

与搭建服务端监控系统类似,在搭建端到端的,应用性能管理系统时,我们也可以从数据的采集、存储和展示几个方面来思考。

首先,在数据采集方面,我们可以采用类似 Agent 的方式,在客户端上植入 SDK,由 SDK 负责采集信息,并且经过采样之后,通过一个固定的接口,定期发送给服务端。这个固定接口和服务端,我们可以称为 APM 通道服务。

虽然客户端需要监控的指标很多,比如监控网络情况,监控客户端卡顿情况、垃圾收集数据等等,但我们可以定义一种通用的数据采集格式。

比如,在我之前的公司里,采集的数据包含下面几个部分,SDK 将这几部分数据转换成 JSON 格式后,就可以发送给 APM 通道服务了。这几部分数据格式,你可以在搭建自己的 APM 系统时,直接拿来参考。

  • 系统部分:包括数据协议的版本号,以及下面提到的消息头、端消息体、业务消息体的长度;
  • 消息头:主要包括应用的标识(appkey),消息生成的时间戳,消息的签名以及消息体加密的秘钥;
  • 端消息体:主要存储客户端的一些相关信息,主要有客户端版本号、SDK 版本号、IDFA、IDFV、IMEI、机器型号、渠道号、运营商、网络类型、操作系统类型、国家、地区、经纬度等等。由于这些信息有些比较敏感,所以我们一般会对信息加密;
  • 业务消息体:也就是真正要采集的数据,这些数据也需要加密。

加密的方法是这样的: 我们首先会分配给这个应用,一对 RSA 公钥和私钥,然后 SDK 在启动的时候,先请求一个策略服务,获取 RSA 公钥。在加密时,客户端会随机生成一个对称加密的秘钥 Key,端消息体和业务消息体,都会使用这个秘钥来加密。那么数据发到 APM 通道服务后,要如何解密呢?

客户端会使用 RSA 的公钥对秘钥加密,存储在消息头里面(也就是上面提到的,消息体加密秘钥),然后 APM 通道服务使用 RSA 秘钥,解密得到秘钥,就可以解密得到端消息体和业务消息体的内容了。

最后,我们把消息头、端消息体、业务消息体还有消息头中的时间戳组装起来,用 MD5 生成摘要后,存储在消息头中(也就是消息的签名)。这样,APM 通道服务在接收到消息后,可以使用同样的算法生成摘要,并且与发送过来的摘要比对,以防止消息被篡改。

数据被采集到 APM 通道服务之后,我们先对 JSON 消息做解析,得到具体的数据,然后发送到消息队列里面。从消息队列里面消费到数据之后,会写一份数据到 Elasticsearch 中,作为原始数据保存起来,再写一份到统计平台,以形成客户端的报表。

NAME

有了这套 APM 通道服务,我们就可以将从客户端上采集到的信息,通过统一的方式上报到服务端做集中处理。这样一来,你就可以收集到客户端上的性能数据和业务数据,能够及时地发现问题了。

那么问题来了:虽然你搭建了客户端监控系统,但是在我们电商系统客户端中可以收集到用户网络数据,卡顿数据等等,你是要把这些信息都监控到位,还是有所侧重呢?要知道,监控的信息不明确,会给问题排查带来不便,而这就是我们接下来探究的问题,也就是你到底需要监控用户的哪些信息。

需要监控用户的哪些信息

在我看来,搭建端到端的监控体系的首要目标,是解决如何监控客户端网络的问题,这是因为我们遇到的客户端问题, 大部分的原因还是出在客户端网络上。

在中国复杂的网络环境下,大的运营商各行其是,各自为政,在不同的地区的链路质量各有不同,而小的运营商又鱼龙混杂,服务质量得不到保障。我给你说一个典型的问题。

之前在讲解 DNS 时,我曾经提到在做 DNS 解析的时候,为了缩短查询的链路,首先会查询运营商的 Local DNS,但是 Local DNS 这个东西很不靠谱,有些小的运营商为了节省流量,他会把一些域名解析到内容缓存服务器上,甚至会解析到广告或者钓鱼网站上去,这就是域名劫持。也有一些运营商它比较懒,自己不去解析域名,而是把解析请求,转发到别的运营商上,这就导致权威 DNS 收到请求的来源 IP 的运营商,是不正确的。这样一来,解析的 IP 和请求源,会来自不同的运营商,形成跨网的流量,导致 DNS 解析时间过长。这些需要我们进行实时地监控,以尽快地发现问题,反馈给运营商来解决。

那么,我们如何采集网络数据呢? 一般来说,我们会用埋点的方式,将网络请求的每一个步骤耗时情况、是否发生错误,都打印下来,我以安卓系统为例,解释一下是如何做的。

安卓一般会使用 OkHttpClient 来请求接口数据,而 OkHttpClient 提供了 EventListner 接口,可以让调用者接收到网络请求事件,比如,开始解析 DNS 事件,解析 DNS 结束的事件等等。那么你就可以埋点计算出,一次网络请求的各个阶段的耗时情况。我写了一段具体的示例代码,计算了一次请求的 DNS 解析时间,你可以拿去参考。

public class HttpEventListener extends EventListener {
    final static AtomicLong nextCallId = new AtomicLong(1L);
    private final long callId;
    private long dnsStartTime;
    private HttpUrl url ;
    public HttpEventListener(HttpUrl url) {
        this.callId = nextCallId.getAndIncrement(); // 初始化唯一标识这次请求的 ID
        this.url = url;
    }
    
    @Override
    public void dnsStart(Call call, String domainName) {
        super.dnsStart(call, domainName);
        this.dnsStartTime = System.nanoTime(); // 记录 dns 开始时间
    }
    @Override
    public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {
        super.dnsEnd(call, domainName, inetAddressList);
        System.out.println("url: " + url.host() + ", Dns time: " + (System.nanoTime() - dnsStartTime)); // 计算 dns 解析的时间
    }
}

有了这个 EventListner,你就可以在初始化 HttpClient 的时候把它注入进去,代码如下:

OkHttpClient.Builder builder = new OkHttpClient.Builder()
        .eventListenerFactory(new Factory() {
            @Override
            public EventListener create(Call call) {
                return new HttpEventListener(call.request().url());
            }
        });

这样,我们可以得出一次请求过程中,经历的一系列过程的时间,其中主要包括下面几项。

  1. 等待时间:异步调用时,请求会首先缓存在本地的队列里面,由专门的 I/O 线程负责,那么在 I/O 线程真正处理请求之前,会有一个等待的时间。
  2. DNS 时间:域名解析时间。
  3. 握手时间:TCP 三次握手的时间。
  4. SSL 时间:如果服务是 HTTPS 服务,那么就会有一个 SSL 认证的时间。
  5. 发送时间:请求包被发送出去的时间。
  6. 首包时间:服务端处理后,客户端收到第一个响应包的时间。
  7. 包接收时间:我们接收到所有数据的时间。
NAME

有了这些数据之后,我们可以通过上面提到的 APM 通道服务,发送给服务端,这样服务端和客户端的同学,就可以从 Elasticsearch 中,查询到原始的数据,也可以对数据做一些聚合处理、统计分析之后,形成客户端请求监控的报表。这样,我们就可以有针对性地对 HTTP 请求的某一个过程做优化了。

而对于用户网络的监控,可以给你带来三方面的价值。

首先,这种用户网络监控的所有监控数据均来自客户端,是用户访问数据实时上报,因此能够准确、真实、实时地反应用户操作体验。

再者,它是我们性能优化的指向标,当做业务架构改造、服务性能优化、网络优化等任何优化行为时,可以反馈用户性能数据,引导业务正向优化接口性能、可用性等指标。

最后,它也能帮助我们监控 CDN 链路质量。之前的 CDN 的监控,严重依赖 CDN 厂商,这有一个问题是:CDN 无法从端上获取到全链路的监控数据,有些时候,客户端到 CDN 的链路出了问题,CDN 厂商是感知不到的,而客户端监控弥补了这方面的缺陷,并且可以通过告警机制督促 CDN 及时优化调整问题线路。

除了上报网络数据之外,我们还可以上报一些异常事件的数据,比如你的垂直电商系统可能会遇到以下一些异常情况。

  • 登录失败
  • 下单失败
  • 浏览商品信息失败
  • 评论列表加载不出来
  • 无法评分留言

你在业务逻辑的代码中,都可以检测到这些异常数据,当然,也可以通过 APM 通道服务,上传到服务端,这样方便服务端和客户端的同学一起来排查问题,也能给你的版本灰度提供数据的支持。

总的来说,如果说搭建的系统是骨架,那么具体监控的数据就是灵魂,因为数据是监控的主题内容,系统只是呈现数据的载体。所以,你需要在系统运维的过程中不断完善对数据的收集,这也是对你的监控系统不断升级完善的过程。

课程小结

以上就是本节课的全部内容了。本节课,我主要带你了解了,如何搭建一个端到端的 APM 监控系统,你需要了解的重点是:

  1. 从客户端采集到的数据可以用通用的消息格式,上传到 APM 服务端,服务端将数据存入到 Elasticsearch 中,以提供原始日志的查询,也可以依据这些数据形成客户端的监控报表;
  2. 用户网络数据是我们排查客户端,和服务端交互过程的重要数据,你可以通过代码的植入,来获取到这些数据;
  3. 无论是网络数据,还是异常数据,亦或是卡顿、崩溃、流量、耗电量等数据,你都可以通过把它们封装成 APM 消息格式,上传到 APM 服务端,这些用户在客户端上留下的踪迹可以帮助你更好地优化用户的使用体验。

总而言之,监测和优化用户的使用体验是应用性能管理的最终目标。 然而,服务端的开发人员往往会陷入一个误区,认为我们将服务端的监控做好,保证接口性能和可用性足够好就好了。事实上,接口的响应时间只是我们监控系统中很小的一部分,搭建一套端到端的全链路的监控体系,才是你的监控系统的最终形态。

31 - CH32-维护-链路压测

经过两节课的学习,我们已经搭建了服务端和客户端的监控,通过监控的报表和一些报警规则的设置,你可以实时地跟踪和解决垂直电商系统中出现的问题了。不过,你不能掉以轻心,因为监控只能发现目前系统中已经存在的问题,对于未来可能发生的性能问题是无能为力的。

一旦你的系统流量有大的增长,比如类似「双十一」的流量,那么你在面临性能问题时就可能会手足无措。为了解决后顾之忧,你需要了解在流量增长若干倍的时候,系统的哪些组件或者服务会成为整体系统的瓶颈点,这时你就需要做一次全链路的压力测试。

那么,什么是压力测试呢?要如何来做全链路的压测呢?这两个问题就是本节课重点讲解的内容。

什么是压力测试

压力测试(简称为压测)这个名词儿,你在业界的分享中一定听过很多次,当然了,你也可能在项目的研发过程中做过压力测试,所以,对于你来说,压力测试并不陌生。

不过,我想让你回想一下,自己是怎么做压力测试的?是不是像很多同学一样:先搭建一套与正式环境功能相同的测试环境,并且导入或者生成一批测试数据,然后在另一台服务器,启动多个线程并发地调用需要压测的接口(接口的参数一般也会设置成相同的,比如,想要压测获取商品信息的接口,那么压测时会使用同一个商品 ID)。最后,通过统计访问日志,或者查看测试环境的监控系统,来记录最终压测 QPS 是多少之后,直接交差?

这么做压力测试其实是不正确的,错误之处主要有以下几点:

  1. 首先,做压力测试时,最好使用线上的数据和线上的环境,因为,你无法确定自己搭建的测试环境与正式环境的差异,是否会影响到压力测试的结果;

  2. 其次,压力测试时不能使用模拟的请求,而是要使用线上的流量。你可以通过拷贝流量的方式,把线上流量拷贝一份到压力测试环境。因为模拟流量的访问模型,和线上流量相差很大,会对压力测试的结果产生比较大的影响。

    比如,你在获取商品信息的时候,线上的流量会获取不同商品的数据,这些商品的数据有的命中了缓存,有的没有命中缓存。如果使用同一个商品 ID 来做压力测试,那么只有第一次请求没有命中缓存,而在请求之后会将数据库中的数据回种到缓存,后续的请求就一定会命中缓存了,这种压力测试的数据就不具备参考性了。

  3. 不要从一台服务器发起流量,这样很容易达到这台服务器性能瓶颈,从而导致压力测试的 QPS 上不去,最终影响压力测试的结果。而且,为了尽量真实地模拟用户请求,我们倾向于把流量产生的机器,放在离用户更近的位置,比如放在 CDN 节点上。如果没有这个条件,那么可以放在不同的机房中,这样可以尽量保证压力测试结果的真实性。

之所以有很多同学出现这个问题,主要是对压力测试的概念没有完全理解,以为只要是使用多个线程并发的请求服务接口,就算是对接口进行压力测试了。

那么究竟什么是压力测试呢? 压力测试指的是,在高并发大流量下,进行的测试,测试人员可以通过观察系统在峰值负载下的表现,从而找到系统中存在的性能隐患。

与监控一样,压力测试是一种常见的,发现系统中存在问题的方式,也是保障系统可用性和稳定性的重要手段。而在压力测试的过程中,我们不能只针对某一个核心模块来做压测,而需要将接入层、所有后端服务、数据库、缓存、消息队列、中间件以及依赖的第三方服务系统及其资源,都纳入压力测试的目标之中。因为,一旦用户的访问行为增加,包含上述组件服务的整个链路都会受到不确定的大流量的冲击,因此,它们都需要依赖压力测试来发现可能存在的性能瓶颈, 这种针对整个调用链路执行的压力测试也称为「全链路压测」。

由于在互联网项目中,功能迭代的速度很快,系统的复杂性也变得越来越高,新增加的功能和代码很可能会成为新的性能瓶颈点。也许半年前做压力测试时,单台机器可以承担每秒 1000 次请求,现在很可能就承担每秒 800 次请求了。所以,压力测试应该作为系统稳定性保障的常规手段,周期性地进行。

但是,通常做一次全链路压力测试,需要联合 DBA、运维、依赖服务方、中间件架构等多个团队,一起协调进行,无论是人力成本还是沟通协调的成本都比较高。同时,在压力测试的过程中,如果没有很好的监控机制,那么还会对线上系统造成不利的影响。 为了解决这些问题,我们需要搭建一套自动化的全链路压测平台,来降低成本和风险。

如何搭建全链路压测平台

搭建全链路压测平台,主要有两个关键点。

一点是流量的隔离。由于压力测试是在正式环境进行,所以需要区分压力测试流量和正式流量,这样可以针对压力测试的流量做单独的处理。

另一点是风险的控制。也就是,尽量避免压力测试对于正常访问用户的影响,因此,一般来说全链路压测平台需要包含以下几个模块:

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

整体压测平台的架构图可以是下面这样的:

NAME

为了让你能够更清晰地了解每一个模块是如何实现的,方便你来设计适合自身业务的全链路压测平台,我会对压测平台的每一个模块做更细致地介绍。先来看看压力测试的流量是如何产生的。

压测数据的产生

一般来说,我们系统的入口流量是来自于客户端的 HTTP 请求,所以,我们会考虑在系统高峰期时,将这些入口流量拷贝一份,在经过一些流量清洗的工作之后(比如过滤一些无效的请求),将数据存储在像是 HBase、MongoDB 这些 NoSQL 存储组件,或者亚马逊 S3 这些云存储服务中,我们称之为 流量数据工厂

这样,当我们要压测的时候,就可以从这个工厂中获取数据,将数据切分多份后下发到多个压测节点上了,在这里,我想强调几个,你需要特殊注意的点。

首先,我们可以使用多种方式来实现流量的拷贝。最简单的一种方式:直接拷贝负载均衡服务器的访问日志,数据就以文本的方式写入到流量数据工厂中,但是这样产生的数据在发起压测时,需要自己写解析的脚本来解析访问日志,会增加压测时候的成本,不太建议使用。

另一种方式:通过一些开源的工具来实现流量的拷贝。这里,我推荐一款轻型的流量拷贝工具 GoReplay,它可以劫持本机某一个端口的流量,将它们记录在文件中,传送到流量数据工厂中。在压测时,你也可以使用这个工具进行加速的流量回放,这样就可以实现对正式环境的压力测试了。

其次,如上文中提到的,我们在下发压测流量时,需要保证下发流量的节点与用户更加接近,起码不能和服务部署节点在同一个机房中,这样可以尽量保证压测数据的真实性。

另外,我们还需要对 压测流量染色,也就是增加压测标记。在实际项目中,我会在 HTTP 的请求头中增加一个标记项,比如说叫做 is stress test,在流量拷贝之后,批量在请求中增加这个标记项,再写入到数据流量工厂中。

数据如何隔离

将压测流量拷贝下来的同时,我们也需要考虑对系统做改造,以实现压测流量和正式流量的隔离,这样一来就会尽量避免压测对线上系统的影响,一般来说,我们需要做两方面的事情。

一方面,针对读取数据的请求(一般称之为下行流量),我们会针对某些不能压测的服务或者组件,做 Mock 或者特殊的处理。举个例子。

在业务开发中,我们一般会依据请求,记录用户的行为,比如,用户请求了某个商品的页面,我们会记录这个商品多了一次浏览的行为,这些行为数据会写入一份单独的大数据日志中,再传输给数据分析部门,形成业务报表给到产品或者老板做业务的分析决策。

在压测的时候,肯定会增加这些行为数据,比如原本一天商品页面的浏览行为是一亿次,而压测之后变成了十亿次,这样就会对业务报表产生影响,影响后续的产品方向的决策。因此,我们对于这些压测产生的用户行为做特殊处理,不再记录到大数据日志中。

再比如,我们系统会依赖一些推荐服务,推荐一些你可能感兴趣的商品,但是这些数据的展示有一个特点就是,展示过的商品就不再会被推荐出来。如果你的压测流量经过这些推荐服务,大量的商品就会被压测流量请求到,线上的用户就不会再看到这些商品了,也就会影响推荐的效果。

所以,我们需要 Mock 这些推荐服务,让不带有压测标记的请求经过推荐服务,而让带有压测标记的请求经过 Mock 服务。搭建 Mock 服务,你需要注意一点:这些 Mock 服务最好部署在真实服务所在的机房,这样可以尽量模拟真实的服务部署结构,提高压测结果的真实性。

另一方面,针对写入数据的请求(一般称之为上行流量),我们会把压测流量产生的数据,写入到影子库,也就是和线上数据存储,完全隔离的一份存储系统中。针对不同的存储类型,我们会使用不同的影子库的搭建方式:

  1. 如果数据存储在 MySQL 中,我们可以在同一个 MySQL 实例,不同的 Schema 中创建一套和线上相同的库表结构,并且把线上的数据也导入进来。
  2. 而如果数据是放在 Redis 中,我们对压测流量产生的数据,增加一个统一的前缀,存储在同一份存储中。
  3. 还有一些数据会存储在 Elasticsearch 中,针对这部分数据,我们可以放在另外一个单独的索引表中。

通过对下行流量的特殊处理以及对上行流量增加影子库的方式,我们就可以实现压测流量的隔离了。

压力测试如何实施

在拷贝了线上流量和完成了对线上系统的改造之后,我们就可以进行压力测试的实施了。在此之前,一般会设立一个压力测试的目标,比如说,整体系统的 QPS 需要达到每秒 20 万。

不过,在压测时,不会一下子把请求量增加到每秒 20 万次,而是按照一定的步长(比如每次压测增加一万 QPS),逐渐地增加流量。在增加一次流量之后,让系统稳定运行一段时间,观察系统在性能上的表现。如果发现依赖的服务或者组件出现了瓶颈,可以先减少压测流量,比如,回退到上一次压测的 QPS,保证服务的稳定,再针对此服务或者组件进行扩容,然后再继续增加流量压测。

为了能够减少压力测试过程中,人力投入成本,可以开发一个流量监控的组件,在这个组件中,预先设定一些性能阈值。比如,容器的 CPU 使用率的阈值可以设定为 60%~70%;系统的平均响应时间的上限可以设定为 1 秒;系统慢请求的比例设置为 1% 等等。

当系统性能达到这个阈值之后,流量监控组件可以及时发现,并且通知压测流量下发组件减少压测流量,并且发送报警给到开发和运维的同学,开发和运维同学就迅速地排查性能瓶颈,在解决问题或者扩容之后再继续执行压测。

业界关于全链路压测平台的探索有很多,一些大厂比如阿里、京东、美团和微博都有了适合自身业务的全链路压测平台。在我看来,这些压测平台万变不离其宗,都无非是经过流量拷贝、流量染色隔离、打压、监控熔断等步骤,与本课程中介绍的核心思想都是相通的。因此,你在考虑自研适合自己项目的全链路压测平台时,也可以遵循这个成熟的套路。

课程小结

本节课,我带你了解了做压力测试常见的误区,以及自动化的全链路压测平台的搭建过程,这里你需要了解的重点是:

  1. 压力测试是一种发现系统性能隐患的重要手段,所以应该尽量使用正式的环境和数据;
  2. 对压测的流量需要增加标记,这样就可以通过 Mock 第三方依赖服务和影子库的方式来实现压测数据和正式数据的隔离;
  3. 压测时,应该实时地对系统性能指标做监控和告警,及时地对出现瓶颈的资源或者服务扩容,避免对正式环境产生影响。

这套全链路的压力测试系统对于我们来说有三方面的价值:

  • 其一,它可以帮助我们发现系统中可能出现的性能瓶颈,方便我们提前准备预案来应对;
  • 其次,它也可以为我们做容量评估,提供数据上的支撑;
  • 最后,我们也可以在压测的时候做预案演练,因为压测一般会安排在流量的低峰期进行,这样我们可以降级一些服务来验证预案效果,并且可以尽量减少对线上用户的影响。

所以,随着你的系统流量的快速增长,你也需要及时考虑搭建这么一套全链路压测平台,来保证你的系统的稳定性。

32 - CH33-维护-配置管理

相信在实际工作中,提及性能优化你会想到代码优化,但是实际上有些性能优化可能只需要调整一些配置参数就可以搞定了,为什么这么说呢?我给你举几个例子:

  • 你可以调整配置的超时时间,让请求快速失败,防止系统的雪崩,提升系统的可用性;
  • 你还可以调整 HTTP 客户端连接池的大小,来提升调用第三方 HTTP 服务的并行处理能力,从而提升系统的性能。

你可以认为,配置是管理你系统的工具,在你的垂直电商系统中,一定会有非常多的配置项,比如数据库的地址、请求 HTTP 服务的域名、本地内存最大缓存数量等等。

那么,你要如何对这些配置项做管理呢?管理的过程中要注意哪些事情呢?

如何对配置进行管理呢?

配置管理由来已久,比如在 Linux 系统中就提供了大量的配置项,你可以依据自身业务的实际情况,动态地对系统功能做调整。比如,

  • 你可以修改 dirty_writeback_centisecs 参数的数值,就可以调整 Page Cache 中脏数据刷新到磁盘上的频率;
  • 你也可以通过修改 tcp_max_syn_backlog 参数置的值,来调整未建立连接队列的长度。

修改这些参数既可以通过修改配置文件并且重启服务器来配置生效,也可以通过 sysctl 命令来动态地调整,让配置即时生效。

那么你在开发应用的时候,都有哪些管理配置的方式呢?主要有两种:

  • 一种是通过配置文件来管理;
  • 另一种是使用配置中心来管理。

以电商系统为例,你和你的团队在刚开始研发垂直电商系统时,为了加快产品的研发速度,大概率不会注意配置管理的问题,会自然而然地把配置项和代码写在一起。但是随着配置项越来越多,为了更好地对配置项进行管理,同时避免修改配置项后还要对代码做重新的编译,你选择把配置项独立成单独的文件(文件可以是 properties 格式、xml 格式或 yaml 格式)。不过,这些文件还是会和工程一起打包部署,只是更改配置后不需要对代码重新编译了。

随后,你很快发现了一个问题: 虽然把配置拆分了出来,但是由于配置还是和代码打包在一起,如果要更改一个配置,还是需要重新打包,这样无疑会增加打包的时间。于是,你考虑把配置写到单独的目录中,这样,修改配置就不需要再重新打包了(不过,由于配置并不能够实时地生效,所以想让配置生效,还是需要重启服务)。

我们一般使用的基础组件,比如 Tomcat,Nginx,都是采用上面这种配置文件的方式来管理配置项的,而在 Linux 系统中,我提到的 tcp_max_syn_backlog 就可以配置在 /etc/sysctl.conf 中。

这里,我需要强调一点,我们通常会把配置文件存储的目录,标准化为特定的目录。 比如,都配置成 /data/confs 目录,然后把配置项使用 Git 等代码仓库管理起来。这样,在增加新的机器时,在机器初始化脚本中,只需要创建这个目录,再从 Git 中拉取配置就可以了,是一个标准化的过程,这样可以避免在启动应用时忘记部署配置文件。

另外,如果你的服务是多机房部署的,那么不同机房的配置项中,有可能有相同的,或者有不同的。那么,你需要将相同的配置项放置在一个目录中给多个机房公用,再将不同的配置项,放置在以机房名为名称的目录中。在读取配置的时候应该优先读取机房的配置,再读取公共配置,这样可以减少配置文件中的配置项的数量。

我给你列了一个典型目录配置,如果你的系统也使用文件来管理配置,那么可以参考一下。

/data/confs/common/commerce // 电商业务的公共配置
/data/confs/commerce-zw     // 电商业务兆维机房配置
/data/confs/commerce-yz     // 电商业务亦庄机房配置
/data/confs/common/community // 社区业务的公共配置
/data/confs/community-zw     // 社区业务兆维机房配置
/data/confs/community-yz     // 社区业务亦庄机房配置

那么,这是不是配置管理的最终形态呢?当然不是,你不要忘了把配置放在文件中的方式还存在的问题(我上面也提到过了),那就是,我们必须将服务重启后,才能让配置生效。有没有一种方法可以在不重启应用的前提下,也能让配置生效呢?这就需要配置中心帮助我们实现了。

配置中心是如何实现的?

配置中心可以算是微服务架构中的一个标配组件了。业界也提供了多种开源方案供你选择,比较出名的有携程开源的 Apollo,百度开源的 Disconf,360 开源的 QConf,Spring Cloud 的组件 Spring Cloud Config 等等。

在我看来,Apollo 支持不同环境,不同集群的配置,有完善的管理功能,支持灰度发布、更改热发布等功能, 在所有配置中心中功能比较齐全,推荐你使用。

那么,配置中心的组件在实现上,有哪些关键的点呢?如果你想对配置中心组件有更强地把控力,想要自行研发一套符合自己业务场景的组件,又要如何入手呢?

配置信息如何存储

其实,配置中心和注册中心非常类似,其核心的功能就是对于 配置项的存储和读取。所以,在设计配置中心的服务端时,我们需要选择合适的存储组件,来存储大量的配置信息,这里可选择的组件有很多。

事实上,不同的开源配置中心也使用了不同的组件,比如 Disconf、Apollo 使用的是 MySQL;QConf 使用的是 ZooKeeper。我之前维护和使用的配置中心就会使用不同的存储组件,比如微博的配置中心使用 Redis 来存储信息,而美图的则使用 Etcd。

而无论使用哪一种存储组件,你所要做的就是规范配置项在其中的存储结构。比如,我之前使用的配置中心用 Etcd 作为存储组件,支持存储全局配置、机房配置和节点配置。其中,节点配置优先级高于机房配置,机房配置优先级高于全局配置。也就是说,我们会优先读取节点的配置,如果节点配置不存在,再读取机房配置,最后读取全局配置。它们的存储路径如下:

/confs/global/{env}/{project}/{service}/{version}/{module}/{key} // 全局配置
/confs/regions/{env}/{project}/{service}/{version}/{region}/{module}/{key}   // 机房配置 
/confs/nodes/{env}/{project}/{service}/{version}/{region}/{node}/{module}/{key}     // 节点配置

变更推送如何实现

配置信息存储之后,我们需要考虑如何将配置的变更推送给服务端,这样就可以实现配置的动态变更,不需要重启服务器就能让配置生效了。而我们一般会有两种思路来实现变更推送:

  • 一种是轮询查询的方式;
  • 一种是长连推送的方式。

轮询查询很简单,就是应用程序向配置中心客户端注册一个监听器,配置中心的客户端,定期地(比如 1 分钟)查询所需要的配置是否有变化,如果有变化则通知触发监听器,让应用程序得到变更通知。

这里有一个需要注意的点, 如果有很多应用服务器都去轮询拉取配置,由于返回的配置项可能会很大,那么配置中心服务的带宽就会成为瓶颈。为了解决这个问题,我们会给配置中心的每一个配置项,多存储一个根据配置项计算出来的 MD5 值。

配置项一旦变化,这个 MD5 值也会随之改变。配置中心客户端在获取到配置的同时,也会获取到配置的 MD5 值,并且存储起来。那么在轮询查询的时候,需要先确认存储的 MD5 值,和配置中心的 MD5 是不是一致的。如果不一致,这就说明配置中心中,存储的配置项有变化,然后才会从配置中心拉取最新的配置。

由于配置中心里存储的配置项变化的几率不大,所以使用这种方式后,每次轮询请求就只是返回一个 MD5 值,可以大大地减少配置中心服务器的带宽。

NAME

另一种长连的方式,则是在配置中心服务端保存每个连接关注的配置项列表。这样,当配置中心感知到配置变化后,就可以通过这个连接,把变更的配置推送给客户端。这种方式需要保持长连,也需要保存连接和配置的对应关系,实现上要比轮询的方式复杂一些,但是相比轮询方式来说,能够更加实时地获取配置变更的消息。

而在我看来,配置服务中存储的配置变更频率不高,所以对于实时性要求不高,但是期望实现上能够足够简单, 所以如果选择自研配置中心的话,可以考虑使用轮询的方式。

如何保证配置中心高可用

除了变更通知以外,在配置中心实现中,另外一个比较关键的点在于如何保证它的可用性,因为对于配置中心来说,它的可用性的重要程度要远远大于性能。这是因为我们一般会在服务器启动时,从配置中心中获取配置,如果配置获取的性能不高,那么外在的表现也只是应用启动时间慢了,对于业务的影响不大;但是,如果获取不到配置,很可能会导致启动失败。

比如,我们把数据库的地址存储在了配置中心里,如果配置中心宕机导致我们无法获取数据库的地址,那么自然应用程序就会启动失败。 因此,我们的诉求是让配置中心「旁路化」。 也就是说,即使配置中心宕机,或者配置中心依赖的存储宕机,我们仍然能够保证应用程序是可以启动的。那么这是如何实现的呢?

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

配置中心客户端在获取到配置信息后,会同时把配置信息同步地写入到内存缓存,并且异步地写入到文件缓存中。内存缓存的作用是降低客户端和配置中心的交互频率,提升配置获取的性能;而文件的缓存的作用就是灾备,当应用程序重启时,一旦配置中心发生故障,那么应用程序就会优先使用文件中的配置,这样虽然无法得到配置的变更消息(因为配置中心已经宕机了),但是应用程序还是可以启动起来的,算是一种降级的方案。

课程小结

以上就是本节课的全部内容了。在这节课中,我带你了解了系统开发的过程中,我们是如何管理大量的配置项的,你需要了解的重点是:

  1. 配置存储是分级的,有公共配置,有个性的配置,一般个性配置会覆盖公共配置,这样可以减少存储配置项的数量;
  2. 配置中心可以提供配置变更通知的功能,可以实现配置的热更新;
  3. 配置中心关注的性能指标中,可用性的优先级是高于性能的,一般我们会要求配置中心的可用性达到 99.999%,甚至会是 99.9999%。

这里你需要注意的是,并不是所有的配置项都需要使用配置中心来存储,如果你的项目还是使用文件方式来管理配置,那么你只需要,将类似超时时间等,需要动态调整的配置,迁移到配置中心就可以了。对于像是数据库地址,依赖第三方请求的地址,这些基本不会发生变化的配置项,可以依然使用文件的方式来管理,这样可以大大地减少配置迁移的成本。

33 - CH34-维护-降级熔断

到目前为止,你的电商系统已经搭建了完善的服务端和客户端监控系统,并且完成了全链路压测。现在呢,你们已经发现和解决了垂直电商系统中很多的性能问题和隐患。但是千算万算,还是出现了纰漏。

本来,你们对于应对「双十一」的考验信心满满,但因为欠缺了一些面对巨大流量的经验,在促销过程中出现了几次短暂的服务不可用,这给部分用户造成了不好的使用体验。事后,你们进行了细致的复盘,追查出现故障的根本原因,你发现,原因主要可以归结为两大类。

  • 第一类原因是由于依赖的资源或者服务不可用,最终导致整体服务宕机。举例来说,在你的电商系统中就可能由于数据库访问缓慢,导致整体服务不可用。
  • 另一类原因是你们乐观地预估了可能到来的流量,当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。

那么,你要如何避免再次出现这两类问题呢?我建议你采取降级、熔断以及限流的方案。限流是解决第二类问题的主要思路(下一节课,我会着重讲解)。今天这节课,我主要讲一下解决第一类问题的思路:降级和熔断。

不过在此之前,我先带你了解一下这个问题为何存在,因为你只有弄清楚出现故障的原理,才能更好地理解熔断降级带来的好处。

雪崩是如何发生的

局部故障最终导致全局故障,这种情况有一个专业的名词儿,叫做「雪崩」。那么,为什么会发生雪崩呢?我们知道,系统在运行的时候是需要消耗一些资源的,包括 CPU、内存等系统资源,也包括执行业务逻辑的时候,需要的线程资源。

举个例子,一般在业务执行的容器内,都会定义一些线程池来分配执行任务的线程,比如在 Tomcat 这种 Web 容器的内部,定义了线程池来处理 HTTP 请求;RPC 框架也给 RPC 服务端初始化了线程池来处理 RPC 请求。

这些线程池中的线程资源是有限的,如果这些线程资源被耗尽,那么服务自然也就无法处理新的请求,服务提供方也就宕机了。比如,你的垂直电商系统有四个服务 A、B、C、D,A 调用 B,B 调用 C 和 D。其中,A、B、D 服务是系统的核心服务(像是电商系统中的订单服务、支付服务等等),C 是非核心服务(像反垃圾服务、审核服务)。

所以,一旦作为入口的 A 流量增加,你可能会考虑把 A、B 和 D 服务扩容,忽略 C。那么 C 就有可能因为无法承担这么大的流量,导致请求处理缓慢,进一步会让 B 在调用 C 的时候,B 中的请求被阻塞,等待 C 返回响应结果。这样一来,B 服务中被占用的线程资源就不能释放。

久而久之,B 就会因为线程资源被占满,无法处理后续的请求。那么从 A 发往 B 的请求,就会被放入 B 服务线程池的队列中,然后 A 调用 B 响应时间变长,进而拖垮 A 服务。你看,仅仅因为非核心服务 C 的响应时间变长,就可以导致整体服务宕机, 这就是我们经常遇到的一种服务雪崩情况。

NAME

那么我们要如何避免出现上面这种情况呢?从我刚刚的介绍中你可以看到,因为服务调用方等待服务提供方的响应时间过长,它的资源被耗尽,才引发了级联反应,发生雪崩。

所以在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机,而是最怕它响应缓慢,因为,某一个服务或者组件宕机也许只会影响系统的部分功能,但它响应一慢,就会出现雪崩拖垮整个系统。

而我们在部门内部讨论方案的时候,会格外注意这个问题,解决的思路就是在检测到某一个服务的响应时间出现异常时,切断调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源。 这个思路也就是我们经常提到的降级和熔断机制。

那么降级和熔断分别是怎么做的呢?它们之间有什么相同点和不同点呢?你在自己的项目中要如何实现熔断降级呢?

熔断机制是如何做的

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

这种实现方式在云计算领域又称为 断路器模式 ,在这种模式下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机中会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。这三种状态之间切换的过程是下面这个样子。

  • 当调用失败的次数累积到一定的阈值时,熔断状态从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。
  • 当熔断处于打开状态时,我们会启动一个超时计时器,当计时器超时后,状态切换到半打开态。你也可以通过设置一个定时器,定期地探测服务是否恢复。
  • 在熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态。
NAME

其实,不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制。在我的团队自己封装的 Redis 客户端中,就实现了一套简单的熔断机制。首先,在系统初始化的时候,我们定义了一个定时器,当熔断器处于 Open 状态时,定期地检测 Redis 组件是否可用:

new Timer("RedisPort-Recover", true).scheduleAtFixedRate(new TimerTask() {
    @Override
    public void run() {
        if (breaker.isOpen()) {
            Jedis jedis = null;
            try {
                jedis = connPool.getResource();
                jedis.ping(); // 验证 redis 是否可用
                successCount.set(0); // 重置连续成功的计数
                breaker.setHalfOpen(); // 设置为半打开态
            } catch (Exception ignored) {
            } finally {
                if (jedis != null) {
                    jedis.close();
                }
            }
        }
    }
}, 0, recoverInterval); // 初始化定时器定期检测 redis 是否可用

在通过 Redis 客户端操作 Redis 中的数据时,我们会在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换,具体的示例代码像下面这样:


if (breaker.isOpen()) { 
    return null;  // 断路器打开则直接返回空值
}
K value = null;
Jedis jedis = null;
try {
     jedis = connPool.getResource();
     value = callback.call(jedis);
     if(breaker.isHalfOpen()) { // 如果是半打开状态
          if(successCount.incrementAndGet() >= SUCCESS_THRESHOLD) {// 成功次数超过阈值
                failCount.set(0);  // 清空失败数
                breaker.setClose(); // 设置为关闭态
          }
     }
     return value;
} catch (JedisException je) {
     if(breaker.isClose()){  // 如果是关闭态
         if(failCount.incrementAndGet() >= FAILS_THRESHOLD){ // 失败次数超过阈值
            breaker.setOpen();  // 设置为打开态
         }
     } else if(breaker.isHalfOpen()) {  // 如果是半打开态
         breaker.setOpen();    // 直接设置为打开态
     }
     throw  je;
} finally {
     if (jedis != null) {
           jedis.close();
     }
}

这样,当某一个 Redis 节点出现问题,Redis 客户端中的熔断器就会实时监测到,并且不再请求有问题的 Redis 节点,避免单个节点的故障导致整体系统的雪崩。

降级机制要如何做

除了熔断之外,我们在听业内分享的时候,听到最多的服务容错方式就是降级,那么降级又是怎么做的呢?它和熔断有什么关系呢?

其实在我看来,相比熔断来说,降级是一个更大的概念。因为它是站在整体系统负载的角度上,放弃部分非核心功能或者服务,保证整体的可用性的方法,是一种有损的系统容错方式。这样看来,熔断也是降级的一种,除此之外还有限流降级、开关降级等等(限流降级我会在下一节课中提到,这节课主要讲一下 开关降级)。

开关降级指的是在代码中预先埋设一些 开关,用来控制服务调用的返回值。比方说,开关关闭的时候正常调用远程服务,开关打开时则执行降级的策略。这些开关的值可以存储在配置中心中,当系统出现问题需要降级时,只需要通过配置中心动态更改开关的值,就可以实现不重启服务快速地降级远程服务了。

还是以电商系统为例,你的电商系统在商品详情页面除了展示商品数据以外,还需要展示评论的数据,但是主体还是商品数据,在必要时可以降级评论数据。所以,你可以定义这个开关为 degrade.comment,写入到配置中心中,具体的代码也比较简单,就像下面这样:

boolean switcherValue = getFromConfigCenter("degrade.comment"); // 从配置中心获取开关的值
if (!switcherValue) {
  List<Comment> comments = getCommentList(); // 开关关闭则获取评论数据
} else {
  List<Comment> comments = new ArrayList(); // 开关打开,则直接返回空评论数据
}

当然了,我们在 设计开关降级预案的时候,首先要区分哪些是核心服务,哪些是非核心服务因为我们只能针对非核心服务来做降级处理 ,然后就可以针对具体的业务,制定不同的降级策略了。我给你列举一些常见场景下的降级策略,你在实际的工作中可以参考借鉴。

  • 针对读取数据的场景,我们一般采用的策略是直接返回降级数据。

    比如,如果数据库的压力比较大,我们在降级的时候,可以考虑只读取缓存的数据,而不再读取数据库中的数据;如果非核心接口出现问题,可以直接返回服务繁忙或者返回固定的降级数据。

  • 对于一些轮询查询数据的场景,比如每隔 30 秒轮询获取未读数,可以降低获取数据的频率(将获取频率下降到 10 分钟一次)。

  • 而对于写数据的场景,一般会考虑把同步写转换成异步写,这样可以牺牲一些数据一致性和实效性来保证系统的可用性。

我想强调的是,只有经过演练的开关才是有用的开关, 有些同学在给系统加了开关之后并不测试,结果出了问题真要使用的时候,却发现开关并不生效。因此,你在为系统增加降级开关时,一定要在流量低峰期的时候做验证演练,也可以在不定期的压力测试过程中演练,保证开关的可用性。

课程小结

以上就是本节课的全部内容了。本节课我带你了解了雪崩产生的原因,服务熔断的实现方式以及服务降级的策略,这里你需要了解的重点是:

  1. 在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务。
  2. 服务熔断的实现是一个有限状态机,关键是三种状态之间的转换过程。
  3. 开关降级的实现策略主要有返回降级数据、降频和异步三种方案。

其实,开关不仅仅应该在你的降级策略中使用,在我的项目中,只要上线新的功能必然要加开关控制业务逻辑是运行新的功能还是运行旧的功能。这样,一旦新的功能上线后,出现未知的问题(比如性能问题),那么可以通过切换开关的方式来实现快速地回滚,减少问题的持续时间。

总之,熔断和降级是保证系统稳定性和可用性的重要手段,在你访问第三方服务或者资源的时候都需要考虑增加降级开关或者熔断机制,保证资源或者服务出现问题时,不会对整体系统产生灾难性的影响。

34 - CH35-维护-流量控制

上一节课里,我带你了解了微服务架构中常见的 两种有损的服务保护策略:熔断和降级。它们都是通过暂时关闭某些非核心服务或者组件从而保护核心系统的可用性。但是,并不是所有的场景下都可以使用熔断降级的策略,比如,电商系统在双十一、618 大促的场景。

这种场景下,系统的峰值流量会超过了预估的峰值,对于核心服务也产生了比较大的影响,而你总不能把核心服务整体降级吧?那么在这个时候要如何保证服务的稳定性呢?你认为可以使用限流的方案。而提到限流,我相信你多多少少在以下几个地方出错过:

  • 限流算法选择不当,导致限流效果不好;
  • 开启了限流却发现整体性能有损耗;
  • 只实现了单机的限流却没有实现整体系统的限流。

说白了,你之所以出现这些问题还是对限流的算法以及实际应用不熟练,而本节课,我将带你了解这些内容,期望你能将这些经验应用到实际项目中,从而提升整体系统的鲁棒性。

究竟什么是限流

限流指的是通过限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则只能通过拒绝服务的方式保证整体系统的可用性 。限流策略一般部署在服务的入口层,比如 API 网关中,这样可以对系统整体流量做塑形。而在微服务架构中,你也可以在 RPC 客户端中引入限流的策略,来保证单个服务不会被过大的流量压垮。

其实,无论在实际工作生活中还是在之前学习过的知识中,你都可能对限流策略有过应用,我给你举几个例子。

比如,到了十一黄金周的时候你想去九寨沟游玩,结果到了九寨沟才发现景区有了临时的通知,每天仅仅售卖 10 万张门票,而当天没有抢到门票的游客就只能第二天起早继续来抢了。这就是一种常见的限流策略,也就是对一段时间内(在这里是一天)流量做整体的控制,它可以避免出现游客过多导致的景区环境受到影响的情况,也能保证游客的安全。而且,如果你挤过地铁,就更能感同身受了。北京早高峰的地铁都会限流,想法很直接,就是控制进入地铁的人数,保证地铁不会被挤爆,也可以尽量保障人们的安全。

再比如,在 TCP 协议中有一个滑动窗口的概念,可以实现对网络传输流量的控制。你可以想象一下,如果没有流量控制,当流量接收方处理速度变慢而发送方还是继续以之前的速率发送数据,那么必然会导致流量拥塞。而 TCP 的滑动窗口实际上可以理解为接收方所能提供的缓冲区的大小。

在接收方回复发送方的 ACK 消息中,会带上这个窗口的大小。这样,发送方就可以通过这个滑动窗口的大小决定发送数据的速率了。如果接收方处理了一些缓冲区的数据,那么这个滑动窗口就会变大,发送方发送数据的速率就会提升;反之,如果接收方接收了一些数据还没有来得及处理,那么这个滑动窗口就会减小,发送方发送数据的速率就会减慢。

NAME

而无论是在一体化架构还是微服务化架构中,我们也可以在多个维度上对到达系统的流量做控制,比如:

  • 你可以对系统每分钟处理多少请求做限制;
  • 可以针对单个接口设置每分钟请求流量的限制;
  • 可以限制单个 IP、用户 ID 或者设备 ID 在一段时间内发送请求的数量;
  • 对于服务于多个第三方应用的开放平台来说,每一个第三方应用对于平台方来说都有一个唯一的 appkey 来标识,那么你也可以限制单个 appkey 的访问接口的速率。

而实现上述限制速率的方式是基于一些限流算法的,那么常见的限流的算法有哪些呢?你在实现限流的时候都有哪些方式呢?

你应该知道的限流算法

固定窗口与滑动窗口的算法

我们知道,限流的目的是限制一段时间内发向系统的总体请求量,比如,限制一分钟之内系统只能承接 1 万次请求,那么最暴力的一种方式就是记录这一分钟之内访问系统的请求量有多少,如果超过了 1 万次的限制,那么就触发限流的策略返回请求失败的错误。如果这一分钟的请求量没有达到限制,那么在下一分钟到来的时候先重置请求量的计数,再统计这一分钟的请求量是否超过限制

这种算法叫做 固定窗口算法 ,在实现它的时候,首先要启动一个定时器定期重置计数,比如你需要限制每秒钟访问次数,那么简单的实现代码是这样的:

private AtomicInteger counter;
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
timer.scheduleAtFixedRate(new Runnable(){
    @Override
    public void run() {
        counter.set(0);
    }
}, 0, 1, TimeUnit.SECONDS);

而限流的逻辑就非常简单了,只需要比较计数值是否大于阈值就可以了:

public boolena isRateLimit() {
  return counter.incrementAndGet() >= allowedLimit;
}

这种算法虽然实现非常简单,但是却有一个很大的缺陷 :无法限制短时间之内的集中流量。假如我们需要限制每秒钟只能处理 10 次请求,如果前一秒钟产生了 10 次请求,这 10 次请求全部集中在最后的 10 毫秒中,而下一秒钟的前 10 毫秒也产生了 10 次请求,那么在这 20 毫秒中就产生了 20 次请求,超过了限流的阈值。但是因为这 20 次请求分布在两个时间窗口内,所以没有触发限流,这就造成了限流的策略并没有生效。

NAME

为了解决这个缺陷,就有了基于滑动窗口的算法。 这个算法的原理是将时间的窗口划分为多个小窗口,每个小窗口中都有单独的请求计数。比如下面这张图,我们将 1s 的时间窗口划分为 5 份,每一份就是 200ms;那么当在 1s 和 1.2s 之间来了一次新的请求时,我们就需要统计之前的一秒钟内的请求量,也就是 0.2s~1.2s 这个区间的总请求量,如果请求量超过了限流阈值那么就执行限流策略。

NAME

滑动窗口的算法解决了 临界时间点上突发流量无法控制的问题,但是却因为要存储每个小的时间窗口内的计数,所以空间复杂度有所增加。

虽然滑动窗口算法解决了窗口边界的大流量的问题,但是它和固定窗口算法一样,还是无法限制短时间之内的集中流量,也就是说无法控制流量让它们更加平滑。 因此,在实际的项目中,我很少使用基于时间窗口的限流算法,而是使用其他限流的算法:一种算法叫做漏桶算法,一种叫做令牌筒算法。

漏桶算法与令牌筒算法

漏桶算法的原理很简单,它就像在流量产生端和接收端之间增加一个漏桶,流量会进入和暂存到漏桶里面,而漏桶的出口处会按照一个固定的速率将流量漏出到接收端(也就是服务接口)。

如果流入的流量在某一段时间内大增,超过了漏桶的承受极限,那么多余的流量就会触发限流策略,被拒绝服务。

经过了漏桶算法之后,随机产生的流量就会被整形成为比较平滑的流量到达服务端,从而避免了突发的大流量对于服务接口的影响。 这很像倚天屠龙记里,九阳真经的口诀:他强由他强,清风拂山岗,他横由他横,明月照大江 。 也就是说,无论流入的流量有多么强横,多么不规则,经过漏桶处理之后,流出的流量都会变得比较平滑。

而在实现时,我们一般会使用消息队列作为漏桶的实现,流量首先被放入到消息队列中排队,由固定的几个队列处理程序来消费流量,如果消息队列中的流量溢出,那么后续的流量就会被拒绝。这个算法的思想是不是与消息队列削峰填谷的作用相似呢?

NAME

另一种令牌桶算法的基本算法是这样的:

  • 如果我们需要在一秒内限制访问次数为 N 次,那么就每隔 1/N 的时间,往桶内放入一个令牌;
  • 在处理请求之前先要从桶中获得一个令牌,如果桶中已经没有了令牌,那么就需要等待新的令牌或者直接拒绝服务;
  • 桶中的令牌总数也要有一个限制,如果超过了限制就不能向桶中再增加新的令牌了。这样可以限制令牌的总数,一定程度上可以避免瞬时流量高峰的问题。
NAME

如果要从这两种算法中做选择,我更倾向于使用令牌桶算法,原因是漏桶算法在面对突发流量的时候,采用的解决方式是缓存在漏桶中, 这样流量的响应时间就会增长,这就与互联网业务低延迟的要求不符;而令牌桶算法可以在令牌中暂存一定量的令牌,能够应对一定的突发流量,所以一般我会使用令牌桶算法来实现限流方案,而 Guava 中的限流方案就是使用令牌桶算法来实现的。

你可以看到,使用令牌桶算法就需要存储令牌的数量,如果是单机上实现限流的话,可以在进程中使用一个变量来存储;但是如果在分布式环境下,不同的机器之间无法共享进程中的变量,我们就一般会使用 Redis 来存储这个令牌的数量。这样的话,每次请求的时候都需要请求一次 Redis 来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。 因此,一个折中的思路是: 我们可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数。

课程小结

以上就是本节课的全部内容了。本节课我带你了解了限流的定义和作用,以及常见的几种限流算法,你需要了解的重点是:

  1. 限流是一种常见的服务保护策略,你可以在整体服务、单个服务、单个接口、单个 IP 或者单个用户等多个维度进行流量的控制;
  2. 基于时间窗口维度的算法有固定窗口算法和滑动窗口算法,两者虽然能一定程度上实现限流的目的,但是都无法让流量变得更平滑;
  3. 令牌桶算法和漏桶算法则能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际项目中应用更多。

限流策略是微服务治理中的标配策略,只是你很难在实际中确认限流的阈值是多少,设置的小了容易误伤正常的请求,设置的大了则达不到限流的目的。所以,一般在实际项目中,我们会把阈值放置在配置中心中方便动态调整;同时,我们可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力,然后再依据这个压测出来的值设置合适的阈值。

35 - CH37-实战-计数系统-1

从今天开始,我们正式进入最后的实战篇。在之前的课程中,我分别从数据库、缓存、消息队列和分布式服务化的角度,带你了解了面对高并发的时候要如何保证系统的高性能、高可用和高可扩展。课程中虽然有大量的例子辅助你理解理论知识,但是没有一个完整的实例帮你把知识串起来。

所以,为了将我们提及的知识落地,在实战篇中,我会以微博为背景,用两个完整的案例带你从实践的角度应对高并发大流量的冲击,期望给你一个更加具体的感性认识,为你在实现类似系统的时候提供一些思路。今天我要讲的第一个案例是如何设计一个支持高并发大存储量的计数系统。

来看这样一个场景: 在地铁上,你也许会经常刷微博、点赞热搜,如果有抽奖活动,再转发一波,而这些与微博息息相关的数据,其实就是微博场景下的 计数数据,细说起来,它主要有几类:

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

微博维度的计数代表了这条微博受欢迎的程度,用户维度的数据(尤其是粉丝数),代表了这个用户的影响力,因此大家会普遍看重这些计数信息。并且在很多场景下,我们都需要查询计数数据(比如首页信息流页面、个人主页面),计数数据访问量巨大,所以需要设计计数系统维护它。

但在设计计数系统时,不少人会出现性能不高、存储成本很大的问题,比如,把计数与微博数据存储在一起,这样每次更新计数的时候都需要锁住这一行记录,降低了写入的并发。在我看来,之所以出现这些问题,还是因为你对计数系统的设计和优化不甚了解,所以要想解决痛点,你有必要形成完备的设计方案。

计数在业务上的特点

首先,你要了解这些计数在业务上的特点是什么,这样才能针对特点设计出合理的方案。在我看来,主要有这样几个特点。

  • 数据量巨大

    据我所知,微博系统中微博条目的数量早已经超过了千亿级别,仅仅计算微博的转发、评论、点赞、浏览等核心计数,其数据量级就已经在几千亿的级别。更何况微博条目的数量还在不断高速地增长,并且随着微博业务越来越复杂,微博维度的计数种类也可能会持续扩展(比如说增加了表态数),因此,仅仅是微博维度上的计数量级就已经过了万亿级别。除此之外,微博的用户量级已经超过了 10 亿,用户维度的计数量级相比微博维度来说虽然相差很大,但是也达到了百亿级别。那么如何存储这些过万亿级别的数字,对我们来说就是一大挑战。

  • 访问量大,对于性能的要求高

    微博的日活用户超过 2 亿,月活用户接近 5 亿,核心服务(比如首页信息流)访问量级到达每秒几十万次,计数系统的访问量级也超过了每秒百万级别,而且在性能方面,它要求要毫秒级别返回结果。

  • 最后,对于可用性、数字的准确性要求高

    一般来讲,用户对于计数数字是非常敏感的,比如你直播了好几个月,才涨了 1000 个粉,突然有一天粉丝数少了几百个,那么你是不是会琢磨哪里出现问题,或者打电话投诉直播平台?

那么,面临着高并发、大数据量、数据强一致要求的挑战,微博的计数系统是如何设计和演进的呢?你又能从中借鉴什么经验呢?

支撑高并发的计数系统要如何设计

刚开始设计计数系统的时候,微博的流量还没有现在这么夸张,我们本着 KISS(Keep It Simple and Stupid)原则,尽量将系统设计的简单易维护,所以,我们使用 MySQL 存储计数的数据,因为它是我们最熟悉的,团队在运维上经验也会比较丰富。举个具体的例子。

假如要存储微博维度(微博的计数,转发数、赞数等等)的数据,你可以这么设计表结构:以微博 ID 为主键,转发数、评论数、点赞数和浏览数分别为单独一列,这样在获取计数时用一个 SQL 语句就搞定了。

select repost_count, comment_count, praise_count, view_count from t_weibo_count where weibo_id = ?

在数据量级和访问量级都不大的情况下,这种方式最简单,所以如果你的系统量级不大,你可以直接采用这种方式来实现。

后来,随着微博的不断壮大,之前的计数系统面临了很多的问题和挑战。

比如微博用户量和发布的微博量增加迅猛,计数存储数据量级也飞速增长,而 MySQL 数据库单表的存储量级达到几千万的时候,性能上就会有损耗。所以我们考虑使用分库分表的方式分散数据量,提升读取计数的性能。

我们用 「weibo_id」作为分区键,在选择分库分表的方式时,考虑了下面两种:

  • 一种方式是选择一种哈希算法对 weibo_id 计算哈希值,然后依据这个哈希值计算出需要存储到哪一个库哪一张表中,具体的方式你可以回顾一下第 9 讲数据库分库分表的内容;
  • 另一种方式是按照 weibo_id 生成的时间来做分库分表,我们在第 10 讲谈到发号器的时候曾经提到,ID 的生成最好带有业务意义的字段,比如生成 ID 的时间戳。所以在分库分表的时候,可以先依据发号器的算法反解出时间戳,然后按照时间戳来做分库分表,比如,一天一张表或者一个月一张表等等。

因为越是最近发布的微博,计数数据的访问量就越大,所以虽然我考虑了两种方案,但是按照时间来分库分表会造成数据访问的不均匀,最后用了哈希的方式来做分库分表。

NAME

与此同时,计数的访问量级也有质的飞越。在微博最初的版本中,首页信息流里面是不展示计数数据的,那么使用 MySQL 也可以承受当时读取计数的访问量。但是后来在首页信息流中也要展示转发、评论和点赞等计数数据了。而信息流的访问量巨大,仅仅靠数据库已经完全不能承担如此高的并发量了。于是我们 考虑使用 Redis 来加速读请求 ,通过部署多个从节点来提升可用性和性能,并且通过 Hash 的方式对数据做分片,也基本上可以保证计数的读取性能。然而,这种数据库 + 缓存的方式有一个弊端:无法保证数据的一致性 ,比如,如果数据库写入成功而缓存更新失败,就会导致数据的不一致,影响计数的准确性。所以,我们完全抛弃了 MySQL,全面使用 Redis 来作为计数的存储组件。

NAME

除了考虑计数的读取性能之外,由于热门微博的计数变化频率相当快,也需要考虑如何提升计数的写入性能。比如,每次在转发一条微博的时候,都需要增加这条微博的转发数,那么如果明星发布结婚、离婚的微博,瞬时就可能会产生几万甚至几十万的转发。如果是你的话,要如何降低写压力呢?

你可能已经想到用消息队列来削峰填谷了,也就是说,我们在转发微博的时候向消息队列写入一条消息,然后在消息处理程序中给这条微博的转发计数加 1。 这里需要注意的一点, 我们可以通过批量处理消息的方式进一步减小 Redis 的写压力,比如像下面这样连续更改三次转发数(我用 SQL 来表示来方便你理解):

UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1; 
UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1;  
UPDATE  t_weibo_count SET repost_count = repost_count +1 WHERE weibo_id = 1; 

这个时候,你可以把它们合并成一次更新:

UPDATE t_weibo_count SET repost_count = repost_count + 3 WHERE weibo_id = 1; 

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

讲到这里,我其实已经告诉你一个支撑高并发查询请求的计数系统是如何实现的了。但是在微博的场景下,计数的量级是万亿的级别,这也给我们提了更高的要求,就是如何在有限的存储成本下实现对于全量计数数据的存取。

你知道,Redis 是使用内存来存储信息,相比于使用磁盘存储数据的 MySQL 来说,存储的成本不可同日而语,比如一台服务器磁盘可以挂载到 2 个 T,但是内存可能只有 128G,这样磁盘的存储空间就是内存的 16 倍。而 Redis 基于通用性的考虑,对于内存的使用比较粗放,存在大量的指针以及额外数据结构的开销,如果要存储一个 KV 类型的计数信息,Key 是 8 字节 Long 类型的 weibo_id,Value 是 4 字节 int 类型的转发数,存储在 Redis 中之后会占用超过 70 个字节的空间,空间的浪费是巨大的。 如果你面临这个问题,要如何优化呢?

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

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

同时,我们也会使用一个大的数组来存储计数信息,存储的位置是基于 weibo_id 的哈希值来计算出来的,具体的算法像下面展示的这样:

插入时:
h1 = hash1(weibo_id) // 根据微博 ID 计算 Hash
h2 = hash2(weibo_id) // 根据微博 ID 计算另一个 Hash,用以解决前一个 Hash 算法带来的冲突
for s in 0,1000
   pos = (h1 + h2*s) % tsize // 如果发生冲突,就多算几次 Hash2
     if(isempty(pos) || isdelete(pos))
         t[ pos ] = item  // 写入数组
查询时:
for s in 0,1000
   pos = (h1 + h2*s) % tsize  // 依照插入数据时候的逻辑,计算出存储在数组中的位置
      if(!isempty(pos) && t[pos]==weibo_id)
         return t[pos]
return 0 
删除时:
insert(FFFF) // 插入一个特殊的标

笔者没太看明白上面这个伪代码的含义是什么,计算几次 hash 不是固定次数吗?查询里面也是循环 1000,找到就返回,如果次数不固定,hash 冲突不就拿到错误的了?

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

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

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

NAME

在经过了上面这些优化之后,我们的计数服务就可以支撑高并发大数据量的考验,无论是在性能上、成本上和可用性上都能够达到业务的需求了。

总的来说,我用微博设计计数系统的例子,并不是仅仅告诉你计数系统是如何做的,而是想告诉你在做系统设计的时候需要了解自己系统目前的痛点是什么,然后再针对痛点来做细致的优化。比如,微博计数系统的痛点是存储的成本,那么我们后期做的事情很多都是围绕着如何使用有限的服务器存储全量的计数数据,即使是对开源组件(Redis)做深度的定制会带来很大的运维成本,也只能被认为是为了实现计数系统而必须要做的权衡。

课程小结

以上就是本节课的全部内容了。本节课我以微博为例带你了解了如何实现一套存储千亿甚至万亿数据的高并发计数系统,这里你需要了解的重点如下:

  1. 数据库 + 缓存的方案是计数系统的初级阶段,完全可以支撑中小访问量和存储量的存储服务。如果你的项目还处在初级阶段,量级还不是很大,那么你一开始可以考虑使用这种方案。
  2. 通过对原生 Redis 组件的改造,我们可以极大地减小存储数据的内存开销。
  3. 使用 SSD+ 内存的方案可以最终解决存储计数数据的成本问题。这个方式适用于冷热数据明显的场景,你在使用时需要考虑如何将内存中的数据做换入换出。

其实,随着互联网技术的发展,已经有越来越多的业务场景需要使用上百 G 甚至几百 G 的内存资源来存储业务数据,但是对于性能或者延迟并没有那么高的要求,如果全部使用内存来存储无疑会带来极大的成本浪费。因此,在业界有一些开源组件也在支持使用 SSD 替代内存存储冷数据,比如 Pika,SSDB,这两个开源组件,我建议你可以了解一下它们的实现原理,这样可以在项目中需要的时候使用。而且,在微博的计数服务中也采用了类似的思路,如果你的业务中也需要使用大量的内存,存储热点比较明显的数据,不妨也可以考虑使用类似的思路。

36 - CH38-实战-计数系统-2

在上一节课中我带你了解了如何设计一套支撑高并发访问和存储大数据量的通用计数系统,我们通过缓存技术、消息队列技术以及对于 Redis 的深度改造,就能够支撑万亿级计数数据存储以及每秒百万级别读取请求了。然而有一类特殊的计数并不能完全使用我们提到的方案,那就是未读数。

未读数也是系统中一个常见的模块,以微博系统为例,你可看到有多个未读计数的场景,比如:

  • 当有人 @你、评论你、给你的博文点赞或者给你发送私信的时候,你会收到相应的未读提醒;
  • 在早期的微博版本中有系统通知的功能,也就是系统会给全部用户发送消息,通知用户有新的版本或者有一些好玩的运营活动,如果用户没有看,系统就会给他展示有多少条未读的提醒。
  • 我们在浏览信息流的时候,如果长时间没有刷新页面,那么信息流上方就会提示你在这段时间有多少条信息没有看。

那当你遇到第一个需求时,要如何记录未读数呢?其实,这个需求可以用上节课提到的通用计数系统来实现,因为二者的场景非常相似。

你可以在计数系统中增加一块儿内存区域,以用户 ID 为 Key 存储多个未读数,当有人 @ 你时,增加你的未读 @的计数;当有人评论你时,增加你的未读评论的计数,以此类推。当你点击了未读数字进入通知页面,查看 @ 你或者评论你的消息时,重置这些未读计数为零。相信通过上一节课的学习,你已经非常熟悉这一类系统的设计了,所以我不再赘述。

那么系统通知的未读数是如何实现的呢?我们能用通用计数系统实现吗?答案是不能的,因为会出现一些问题。

系统通知的未读数要如何设计

来看具体的例子。假如你的系统中只有 A、B、C 三个用户,那么你可以在通用计数系统中增加一块儿内存区域,并且以用户 ID 为 Key 来存储这三个用户的未读通知数据,当系统发送一个新的通知时,我们会循环给每一个用户的未读数加 1,这个处理逻辑的伪代码就像下面这样:

List<Long> userIds = getAllUserIds();
for(Long id : userIds) {
  incrUnreadCount(id);
}

这样看来,似乎简单可行,但随着系统中的用户越来越多,这个方案存在两个致命的问题。

首先,获取全量用户就是一个比较耗时的操作,相当于对用户库做一次全表的扫描,这不仅会对数据库造成很大的压力,而且查询全量用户数据的响应时间是很长的,对于在线业务来说是难以接受的。如果你的用户库已经做了分库分表,那么就要扫描所有的库表,响应时间就更长了。不过有一个折中的方法, 那就是在发送系统通知之前,先从线下的数据仓库中获取全量的用户 ID,并且存储在一个本地的文件中,然后再轮询所有的用户 ID,给这些用户增加未读计数。

这似乎是一个可行的技术方案,然而它给所有人增加未读计数,会消耗非常长的时间。你计算一下,假如你的系统中有一个亿的用户,给一个用户增加未读数需要消耗 1ms,那么给所有人都增加未读计数就需要 100000000 * 1 /1000 = 100000 秒,也就是超过一天的时间;即使你启动 100 个线程并发的设置,也需要十几分钟的时间才能完成,而用户很难接受这么长的延迟时间。

另外,使用这种方式需要给系统中的每一个用户都记一个未读数的值,而在系统中,活跃用户只是很少的一部分,大部分的用户是不活跃的,甚至从来没有打开过系统通知,为这些用户记录未读数显然是一种浪费。

通过上面的内容,你可以知道为什么我们不能用通用计数系统实现系统通知未读数了吧?那正确的做法是什么呢?

要知道,系统通知实际上是存储在一个大的列表中的,这个列表对所有用户共享,也就是所有人看到的都是同一份系统通知的数据。不过不同的人最近看到的消息不同,所以每个人会有不同的未读数。因此,你可以记录一下在这个列表中每个人看过最后一条消息的 ID,然后统计这个 ID 之后有多少条消息,这就是未读数了。

NAME

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

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

这是一种比较通用的方案,即节省内存,又能尽量减少获取未读数的延迟。 这个方案适用的另一个业务场景是全量用户打点的场景,比如像下面这张微博截图中的红点。

NAME

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

首先,我们为每一个用户存储一个时间戳,代表最近点过这个红点的时间,用户点了红点,就把这个时间戳设置为当前时间;然后,我们也记录一个全局的时间戳,这个时间戳标识最新的一次打点时间,如果你在后台操作给全体用户打点,就更新这个时间戳为当前时间。而我们在判断是否需要展示红点时,只需要判断用户的时间戳和全局时间戳的大小,如果用户时间戳小于全局时间戳,代表在用户最后一次点击红点之后又有新的红点推送,那么就要展示红点,反之,就不展示红点了。

NAME

这两个场景的共性是 全部用户共享一份有限的存储数据,每个人只记录自己在这份存储中的偏移量,就可以得到未读数了。

你可以看到,系统消息未读的实现方案不是很复杂,它通过设计避免了操作全量数据未读数,如果你的系统中有这种打红点的需求,那我建议你可以结合实际工作灵活使用上述方案。

最后一个需求关注的是微博信息流的未读数,在现在的社交系统中,关注关系已经成为标配的功能,而基于关注关系的信息流也是一种非常重要的信息聚合方式,因此,如何设计信息流的未读数系统就成了你必须面对的一个问题。

如何为信息流的未读数设计方案

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

  • 首先,微博的信息流是基于关注关系的,未读数也是基于关注关系的

    就是说,你关注的人发布了新的微博,那么你作为粉丝未读数就要增加 1。如果微博用户都是像我这样只有几百粉丝的 「小透明」 就简单了,你发微博的时候系统给你粉丝的未读数增加 1 不是什么难事儿。但是对于一些动辄几千万甚至上亿粉丝的微博大 V 就麻烦了,增加未读数可能需要几个小时。假设你是杨幂的粉丝,想了解她实时发布的博文,那么如果当她发布博文几个小时之后,你才收到提醒,这显然是不能接受的。所以未读数的延迟是你在设计方案时首先要考虑的内容。

  • 其次,信息流未读数请求量极大、并发极高,这是因为接口是客户端轮询请求的,不是用户触发的。也就是说,用户即使打开微博客户端什么都不做,这个接口也会被请求到。在几年前,请求未读数接口的量级就已经接近每秒 50 万次,这几年随着微博量级的增长,请求量也变得更高。而作为微博的非核心接口,我们不太可能使用大量的机器来抗未读数请求,因此,如何使用有限的资源来支撑如此高的流量是这个方案的难点。

  • 最后,它不像系统通知那样有共享的存储,因为每个人关注的人不同,信息流的列表也就不同,所以也就没办法采用系统通知未读数的方案。

那要如何设计能够承接每秒几十万次请求的信息流未读数系统呢?你可以这样做:

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

假如用户 A,像上图这样关注了用户 B、C、D,其中 B 发布的博文数是 10,C 发布的博文数是 8,D 发布的博文数是 14,而在用户 A 最近一次查看未读消息时,记录在快照中的这三个用户的博文数分别是 6、7、12,因此用户 A 的未读数就是(10-6)+(8-7)+(14-12)=7。

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

当然了这个方案也有一些缺陷,比如说快照中需要存储关注关系,如果关注关系变更的时候更新不及时,那么就会造成未读数不准确;快照采用的是全缓存存储,如果缓存满了就会剔除一些数据,那么被剔除用户的未读数就变为 0 了。但是好在用户对于未读数的准确度要求不高(未读 10 条还是 11 条,其实用户有时候看不出来),因此,这些缺陷也是可以接受的。

通过分享未读数系统设计这个案例,我想给你一些建议:

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

课程小结

以上就是本节课的全部内容了,本节课我带你了解了未读数系统的设计,这里你需要了解的重点是:

  • 评论未读、@未读、赞未读等一对一关系的未读数可以使用上节课讲到的通用计数方案来解决;

    通用计数器:一个用户一类的计数用一个字段存储就可以了,相对简单,就是存储量很大

  • 在系统通知未读、全量用户打点等存在有限的共享存储的场景下,可以通过记录用户上次操作的时间或者偏移量,来实现未读方案;

    内容共享,通过最后一次读时间或则 id 的偏移量来计算,要注意的是:这种未读不是指你有 10 篇没有读的文章,读取一篇就减少 1 这种未读统计,而是你总共有多少未读消息数量,点击后就重置为 0 了

  • 最后,信息流未读方案最为复杂,采用的是记录用户博文数快照的方式。

    这里和系统全量打点的方式类似,也是点击未读后,计数器就重置为 0 了。但是它有关系计算这是和全量打点方式不一样的地方

这里你可以看到,这三类需求虽然都和未读数有关,但是需求场景不同、对于量级的要求不同,设计出来的方案也就不同。因此,就像我刚刚提到的样子,你在做方案设计的时候,要分析需求的场景,比如说数据的量级是怎样的,请求的量级是怎样的,有没有一些可以利用的特点(比如系统通知未读场景下的有限共享存储、信息流未读场景下关注人数是有限的等等),然后再制定针对性的方案,切忌盲目使用之前的经验套用不同的场景,否则就可能造成性能的下降,甚至危害系统的稳定性。

37 - CH39-实战-信息流-1

前两节课中,我带你探究了如何设计和实现互联网系统中一个常见模块——计数系统。它的业务逻辑其实非常简单,基本上最多只有三个接口,获取计数、增加计数和重置计数。所以我们在考虑方案的时候考察点也相对较少,基本上使用缓存就可以实现一个兼顾性能、可用性和鲁棒性的方案了。然而大型业务系统的逻辑会非常复杂,在方案设计时通常需要灵活运用多种技术,才能共同承担高并发大流量的冲击。那么接下来,我将带你了解如何设计社区系统中最为复杂、并发量也最高的信息流系统。这样,你可以从中体会怎么应用之前学习的组件了。

最早的信息流系统起源于微博,我们知道,微博是基于关注关系来实现内容分发的 ,也就是说,如果用户 A 关注了用户 B,那么用户 A 就需要在自己的信息流中,实时地看到用户 B 发布的最新内容,这是微博系统的基本逻辑,也是它能够让信息快速流通、快速传播的关键。 由于微博的信息流一般是按照时间倒序排列的,所以我们通常把信息流系统称为 TimeLine(时间线)。那么当我们设计一套信息流系统时需要考虑哪些点呢?

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

首先,我们需要 关注延迟数据 ,也就是说,你关注的人发了微博信息之后,信息需要在短时间之内出现在你的信息流中。

其次,我们需要考虑如何支撑高并发的访问。信息流是微博的主体模块,是用户进入到微博之后最先看到的模块,因此它的并发请求量是最高的,可以达到每秒几十万次请求。

最后,信息流拉取性能直接影响用户的使用体验。微博信息流系统中需要聚合的数据非常多,你打开客户端看一看,想一想其中需要聚合哪些数据?主要是微博的数据,用户的数据,除此之外,还需要查询微博是否被赞、评论点赞转发的计数、是否被关注拉黑等等。聚合这么多的数据就需要查询多次缓存、数据库、计数器,而在每秒几十万次的请求下,如何保证在 100ms 之内完成这些查询操作,展示微博的信息流呢?这是微博信息流系统最复杂之处,也是技术上最大的挑战。

那么我们怎么设计一套支撑高并发大流量的信息流系统呢?一般来说,会有两个思路:一个是基于推模式,另一个是基于拉模式。

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

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

假设微博系统是一个邮箱系统,那么用户发送的微博可以认为是进入到一个发件箱,用户的信息流可以认为是这个人的收件箱。推模式的做法是在用户发布一条微博时,除了往自己的发件箱里写入一条微博,同时也会给他的粉丝收件箱里写入一条微博。

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

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 就可以了:

select feedId from inbox where userId = "B";

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

推模式存在的问题和解决思路

你看,按照这个思路就可以实现一套完整的微博信息流系统,也比较符合我们的常识。但是,这个方案会存在一些问题。

首先,就是消息延迟。在讲系统通知未读数的时候,我们曾经提到过,不能采用遍历全量用户给他们加未读数的方式,原因是遍历一次全量用户的延迟很高,而推模式也有同样的问题。对明星来说,他们的粉丝数庞大,如果在发微博的同时还要将微博写入到上千万人的收件箱中,那么发微博的响应时间会非常慢,用户根本没办法接受。因此,我们一般会使用消息队列来消除写入的峰值,但即使这样,由于写入收件箱的消息实在太多,你还是有可能在几个小时之后才能够看到明星发布的内容,这会非常影响用户的使用体验。

NAME

在推模式下,你需要关注的是微博的写入性能,因为用户每发一条微博,都会产生多次的数据库写入。为了尽量减少微博写入的延迟,我们可以从两方面来保障。

  • 一方面,在消息处理上,你可以启动多个线程并行地处理微博写入的消息。
  • 另一方面,由于消息流在展示时可以使用缓存来提升读取性能,所以我们应该尽量保证数据写入数据库的性能,必要时可以采用写入性能更好的数据库存储引擎。

比如,我在网易微博的时候就是采用推模式来实现微博信息流的。当时为了提升数据库的插入性能,我们采用了 TokuDB 作为 MySQL 的存储引擎,这个引擎架构的核心是一个名为分形树的索引结构(Fractal Tree Indexes)。我们知道数据库在写入的时候会产生对磁盘的随机写入,造成磁盘寻道,影响数据写入的性能;而分形树结构和我们在11 讲中提到的 LSM 一样,可以将数据的随机写入转换成顺序写入,提升写入的性能。另外,TokuDB 相比于 InnoDB 来说,数据压缩的性能更高,经过官方的测试,TokuDB 可以将存储在 InnoDB 中的 4TB 的数据压缩到 200G,这对于写入数据量很大的业务来说也是一大福音。然而,相比于 InnoDB 来说,TokuDB 的删除和查询性能都要差一些,不过可以使用缓存加速查询性能,而微博的删除频率不高,因此这对于推模式下的消息流来说影响有限。

其次,存储成本很高。在这个方案中我们一般会这么来设计表结构:

先设计一张 Feed 表,这个表主要存储微博的基本信息,包括微博 ID、创建人的 ID、创建时间、微博内容、微博状态(删除还是正常)等等,它使用微博 ID 做哈希分库分表;

另外一张表是用户的发件箱和收件箱表,也叫做 TimeLine 表(时间线表),主要有三个字段,用户 ID、微博 ID 和创建时间。它使用用户的 ID 做哈希分库分表。

NAME

由于推模式需要给每一个用户都维护一份收件箱的数据,所以数据的存储量极大,你可以想一想,谢娜的粉丝目前已经超过 1.2 亿,那么如果采用推模式的话,谢娜每发送一条微博就会产生超过 1.2 亿条的数据,多么可怕! 我们的解决思路是: 除了选择压缩率更高的存储引擎之外,还可以定期地清理数据,因为微博的数据有比较明显的实效性,用户更加关注最近几天发布的数据,通常不会翻阅很久之前的微博,所以你可以定期地清理用户的收件箱,比如只保留最近 1 个月的数据就可以了。

除此之外,推模式下我们还通常会遇到扩展性的问题。在微博中有一个分组的功能,它的作用是你可以将关注的人分门别类,比如你可以把关注的人分为明星、技术、旅游等类别,然后把杨幂放入明星分类里,将 InfoQ 放在技术类别里。 那么引入了分组之后,会对推模式有什么样的影响呢? 首先是一个用户不止有一个收件箱,比如我有一个全局收件箱,还会针对每一个分组再分别创建一个收件箱,而一条微博在发布之后也需要被复制到更多的收件箱中了。

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

最后,在处理取消关注和删除微博的逻辑时会更加复杂。比如当杨幂删除了一条微博,那么如果要删除她所有粉丝收件箱中的这条微博,会带来额外的分发压力,我们还是尽量不要这么做。

而如果你将一个人取消关注,那么需要从你的收件箱中删除这个人的所有微博,假设他发了非常多的微博,那么即使你之后很久不登录,也需要从你的收件箱中做大量的删除操作,有些得不偿失。 所以你可以采用的策略是: 在读取自己信息流的时候,判断每一条微博是否被删除以及你是否还关注这条微博的作者,如果没有的话,就不展示这条微博的内容了。使用了这个策略之后,就可以尽量减少对于数据库多余的写操作了。

那么说了这么多,推模式究竟适合什么样的业务的场景呢? 在我看来,它比较适合于一个用户的粉丝数比较有限的场景,比如说微信朋友圈,你可以理解为我在微信中增加一个好友是关注了他也被他关注,所以好友的上限也就是粉丝的上限(朋友圈应该是 5000)。有限的粉丝数可以保证消息能够尽量快地被推送给所有的粉丝,增加的存储成本也比较有限。如果你的业务中粉丝数是有限制的,那么在实现以关注关系为基础的信息流时,也可以采用推模式来实现。

课程小结

以上就是本节课的全部内容了。本节课我带你了解以推模式实现信息流的方案以及这个模式会存在哪些问题和解决思路,这里你需要了解的重点是:

  1. 推模式就是在用户发送微博时,主动将微博写入到他的粉丝的收件箱中;
  2. 推送信息是否延迟、存储的成本、方案的可扩展性以及针对取消关注和微博删除的特殊处理是推模式的主要问题;
  3. 推模式比较适合粉丝数有限的场景。

你可以看到,其实推模式并不适合微博这种动辄就有上千万粉丝的业务,因为这种业务特性带来的超高的推送消息延迟以及存储成本是难以接受的,因此,我们要么会使用基于拉模式的实现,要么会使用基于推拉结合模式的实现。那么这两种方案是如何实现的呢?他们在实现中会存在哪些坑呢?又要如何解决呢?我将在下节课中带你着重了解。

38 - CH40-实战-信息流-2

在前一节课中,我带你了解了如何用推模式来实现信息流系统,从中你应该了解到了推模式存在的问题,比如它在面对需要支撑很大粉丝数量的场景时,会出现 消息推送延迟、存储成本高、方案可扩展性差等问题 。虽然我们也会有一些应对的措施,比如说选择插入性能更高的数据库存储引擎来提升数据写入速度,降低数据推送延迟;定期删除冷数据以减小存储成本等等,但是由于微博大 V 用户粉丝量巨大,如果我们使用推模式实现信息流系统,那么只能缓解这些用户的微博推送延迟问题,没有办法彻底解决。

这个时候你可能会问了:那么有没有一种方案可以一劳永逸地解决这个问题呢?当然有了,你不妨试试用拉模式来实现微博信息流系统。那么具体要怎么做呢?

如何使用拉模式设计信息流系统

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

按照这个思路实现微博信息流系统的时候你会发现:用户的收件箱不再有用,因为信息流数据不再出自收件箱,而是出自发件箱。发件箱里是用户关注的所有人数据的聚合。因此用户在发微博的时候就只需要写入自己的发件箱,而不再需要推送给粉丝的收件箱了,这样在获取信息流的时候,就要查询发件箱的数据了。

这个逻辑我还用 SQL 的形式直观地表达出来,方便你理解。假设用户 A 关注了用户 B、C、D,那么当用户 B 发送一条微博的时候,他会执行这样的操作:

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

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

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

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

首先,拉模式彻底解决了推送延迟的问题,大 V 发微博的时候不再需要推送到粉丝的收件箱,自然就不存在延迟的问题了。

其次,存储成本大大降低了。在推模式下,谢娜的粉丝有 1.2 亿,那么谢娜发送一条微博就要被复制 1.2 亿条,写入到存储系统中。在拉模式下只保留了发件箱,微博数据不再需要复制,成本也就随之降低了。

最后,功能扩展性更好了。比如,微博增加了分组的功能,而你想把关注的 A 和 B 分成一个单独的组,那么 A 和 B 发布的微博就形成了一个新的信息流,这个信息流要如何实现呢?很简单,你只需要查询这个分组下所有用户(也就是 A 和 B),然后查询这些用户的发件箱,再把发件箱中的数据,按照时间倒序重新排序聚合就好了。

List<Long> uids = getFromGroup(groupId); // 获取分组下的所有用户
Long<List<Long>> ids = new ArrayList<List<Long>>();
for(Long id : uids) {
  ids.add(getOutboxByUid(id)); // 获取发件箱的内容 id 列表
}
return merge(ids); // 合并排序所有的 id

拉模式之所以可以解决推模式下的所有问题,是因为在业务上关注数始终是有上限的,那么它是不是一个无懈可击的方案呢? 当然不是,拉模式也会有一些问题,在我看来主要有这样两点。

第一点,不同于推模式下获取信息流的时候,只是简单地查询收件箱中的数据,在拉模式下,我们需要对多个发件箱的数据做聚合,这个查询和聚合的成本比较高。微博的关注上限是 2000,假如你关注了 2000 人,就要查询这 2000 人发布的微博信息,然后再对查询出来的信息做聚合。

那么,如何保证在毫秒级别完成这些信息的查询和聚合呢?答案还是缓存。我们可以把用户发布的微博 ID 放在缓存中,不过如果把全部用户的所有微博都缓存起来,消耗的硬件成本也是很高的。所以我们需要关注用户浏览信息流的特点,看看是否可能对缓存的存储成本做一些优化。

在实际执行中,我们对用户的浏览行为做了抽量分析,发现 97% 的用户都是在浏览最近 5 天之内的微博 ,也就是说,用户很少翻看五天之前的微博内容,所以我们只缓存了每个用户最近 5 天发布的微博 ID。假设我们部署 6 个缓存节点来存储这些微博 ID,在每次聚合时并行从这几个缓存节点中批量查询多个用户的微博 ID,获取到之后再在应用服务内存中排序后就好了,这就是对缓存的 6 次请求,可以保证在 5 毫秒之内返回结果。

第二,缓存节点的带宽成本比较高。你想一下,假设微博信息流的访问量是每秒 10 万次请求,也就是说,每个缓存节点每秒要被查询 10 万次。假设一共部署 6 个缓存节点,用户人均关注是 90,平均来说每个缓存节点要存储 15 个用户的数据。如果每个人平均每天发布 2 条微博,5 天就是发布 10 条微博,15 个用户就要存储 150 个微博 ID。每个微博 ID 要是 8 个字节,150 个微博 ID 大概就是 1kB 的数据,单个缓存节点的带宽就是 1kB * 10 万 = 100MB,基本上跑满了机器网卡带宽了。 那么我们要如何对缓存的带宽做优化呢?

在14 讲中我提到,部署多个缓存副本提升缓存可用性,其实,缓存副本也可以分摊带宽的压力。我们知道在部署缓存副本之后,请求会先查询副本中的数据,只有不命中的请求才会查询主缓存的数据。假如原本缓存带宽是 100M,我们部署 4 组缓存副本,缓存副本的命中率是 60%,那么主缓存带宽就降到 100M * 40% = 40M,而每组缓存副本的带宽为 100M / 4 = 25M,这样每一组缓存的带宽都降为可接受的范围之内了。

NAME

在经过了上面的优化之后,基本上完成了基于拉模式信息流系统方案的设计,你在设计自己的信息流系统时可以参考借鉴这个方案。另外,使用缓存副本来抗流量也是一种常见的缓存设计思路,你在项目中必要的时候也可以使用。

推拉结合的方案是怎样的

但是,有的同学可能会说:我在系统搭建初期已经基于推模式实现了一套信息流系统,如果把它推倒重新使用拉模式实现的话,系统的改造成本未免太高了。有没有一种基于推模式的折中的方案呢?

其实我在网易微博的时候,网易微博的信息流就是基于推模式来实现的,当用户的粉丝量大量上涨之后, 我们通过对原有系统的改造实现了一套推拉结合的方案,也能够基本解决推模式存在的问题,具体怎么做呢?

方案的核心在于大 V 用户在发布微博的时候,不再推送到全量用户,而是只推送给活跃的用户。这个方案在实现的时候有几个关键的点。

首先,我们要如何判断哪些是大 V 用户呢? 或者说,哪些用户在发送微博时需要推送全量用户,哪些用户需要推送活跃用户呢?在我看来,还是应该以粉丝数作为判断标准,比如,粉丝数超过 50 万就算作大 V,需要只推送活跃用户。

其次,我们要如何标记活跃用户呢? 活跃用户可以定义为最近几天内在微博中有过操作的用户,比如说刷新过信息流、发布过微博、转发评论点赞过微博,关注过其他用户等等,一旦有用户有过这些操作,我们就把他标记为活跃的用户。

而对大 V 来说,我们可以存储一个活跃粉丝的列表,这个列表里面就是我们标记的活跃用户。当某一个用户从不活跃用户变为活跃用户时,我们会查询这个用户的关注者中哪些是大 V,然后把这个用户写入到这些大 V 的活跃粉丝列表里面,这个活跃粉丝列表是定长的,如果活跃粉丝数量超过了长度,就把最先加入的粉丝从列表里剔除,这样可以保证推送的效率。

最后,一个用户被从活跃粉丝列表中剔除,或者是他从不活跃变成了活跃后,由于他不在大 V 用户的活跃粉丝列表中,所以也就不会收到微博的实时推送,因此,我们需要异步地把大 V 用户最近发布的微博插入到他的收件箱中,保证他的信息流数据的完整性。

NAME

采用推拉结合的方式可以一定程度上弥补推模式的缺陷,不过也带来了一些维护的成本,比如说系统需要维护用户的在线状态,还需要多维护一套活跃的粉丝列表数据,在存储上的成本就更高了。

因此,这种方式一般适合中等体量的项目,当粉丝量级在百万左右,活跃粉丝数量在 10 万级别时,一般可以实现比较低的信息传播延迟以及信息流获取延迟,但是当你的粉丝数量继续上涨,流量不断提升之后,无论是活跃粉丝的存储还是推送的延迟都会成为瓶颈,所以改成拉模式会更好的支撑业务。

课程小结

以上就是本节课的全部内容了。本节课我带你了解了基于拉模式和推拉结合模式实现信息流系统的方案,这里你需要了解的几个重点是:

  1. 在拉模式下,我们只需要保存用户的发件箱,用户的信息流是通过 聚合关注者发件箱数据 来实现的;
  2. 拉模式会有比较大的聚合成本,缓存节点也会存在带宽的瓶颈,所以我们可以通过一些权衡策略尽量减少获取数据的大小,以及部署缓存副本的方式来抗并发;
  3. 推拉结合的模式核心是只推送活跃的粉丝用户,需要维护用户的在线状态以及活跃粉丝的列表,所以需要增加多余的空间成本来存储,这个你需要来权衡。

拉模式和推拉结合模式比较适合微博这种粉丝量很大的业务场景,因为它们都会有比较可控的消息推送延迟。你可以看到,在这两节课程中我们灵活使用数据库分库分表、缓存消息队列、发号器等技术,实现了基于推模式、拉模式以及推拉结合模式的信息流系统,你在做自己系统的方案设计时,应该充分发挥每种技术的优势,权衡业务自身的特性,最终实现技术和业务上的平衡,也就是既能在业务上满足用户需求,又能在技术上保证系统的高性能和高可用。