从古至今,长江和黄河流域水患不断,远古时期大禹曾拓宽河道,清除淤沙让流水更加顺畅;都江堰作为史上最成功的的治水案例之一,用引流将岷江之水分流到多个支流中,以分担水流压力;三门峡和葛洲坝通过建造水库将水引入水库先存储起来,然后再想办法把水库中的水缓缓地排出去,以此提高下游的抗洪能力。而我们在应对高并发大流量时也会采用类似“抵御洪水”的方案,归纳起来共有三种方法。
-
Scale-out(横向扩展):分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。
-
缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击。
-
异步:在某些场景下,未处理完成之前我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求。
这三种方法可以在做方案设计时灵活地运用,但它不是具体实施的方案,而是三种思想,在实际运用中会千变万化。
一 . 数据库篇
池化技术减少频繁创建连接的损耗
一般sql的平均执行时间是1ms,而数据库建立连接的时间需要4ms,这样一秒大概执行200次数据库查询,如果我们事先用连接池将数据库连接预先建立好,这样在使用的时候就不需要频繁地创建连接了。调整之后,你发现 1s 就可以执行 1000 次的数据库查询,查询性能大大的提升了。
在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心,以数据库连接池为例,来说明一下连接池管理的关键点。数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程: 如果当前连接数小于最小连接数,则创建新的连接处理数据库请求; 如果连接池中有空闲连接则复用空闲连接; 如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求; 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用; 如果等待超过了这个设定时间则向用户抛出错误。 对于数据库连接池,一般在线上建议最小连接数控制在 10 左右,最大连接数控制在 20~30 左右即可。那么,怎么保证你启动着的连接一定是可用的呢?
-
启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送“select 1”的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用。
-
在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引入多余的开销,在线上系统中还是尽量不要开启,在测试服务上可以使用。
顺着这个思路,线程池的基本原理也是如此。这是一种常见的软件设计思想,叫做池化技术,它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本,总之是好处多多。不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间。池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。
主从分离优化查询请求
大部分系统的访问模型是读多写少,读写请求量的差距可能达到几个数量级。这很好理解,刷朋友圈的请求量肯定比发朋友圈的量大,淘宝上一个商品的浏览量也肯定远大于它的下单量。因此,我们优先考虑数据库如何抗住更高的查询请求,那么首先你需要把读写流量区分开,因为这样才方便针对读流量做单独的扩展,这就是我们所说的主从读写分离。
一般来说在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或者多份,并且写入到其它的数据库服务器中,原始的数据库我们称为主库,主要负责数据的写入,拷贝的目标数据库称为从库,主要负责支持数据查询。可以看到,主从读写分离有两个技术上的关键点:
一个是数据的拷贝,我们称为主从复制; 在主从分离的情况下,我们如何屏蔽主从分离带来的访问数据库方式的变化,让开发同学像是在使用单一数据库一样。 MySQL 的主从复制是依赖于 binlog 的,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上二进制日志文件。主从复制就是将 binlog 中的数据从主库传输到从库上,一般这个过程是异步的,即主库上的操作不会等待 binlog 同步的完成。
主从复制的过程是这样的:首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中,而主库也会创建一个 log dump 线程来发送 binlog 给从库;同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。
在这个方案中,使用独立的 log dump 线程是一种异步的方式,可以避免对主库的主体更新流程产生影响,而从库在接收到信息后并不是写入从库的存储中,是写入一个 relay log,是避免写入从库实际存储会比较耗时,最终造成从库和主库延迟变长。
那么你可能会说,是不是我无限制地增加从库的数量就可以抵抗大量的并发呢?实际上并不是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3~5 个从库。
分库分表优化大量写入请求
在 4 核 8G 的云服务器上对 MySQL 5.7 做 Benchmark,大概可以支撑 500TPS 和 10000QPS,你可以看到数据库对于写入性能要弱于数据查询的能力,那么随着系统写入请求量的增长,数据库系统如何来处理更高的并发写入请求呢?这些问题你可以归纳成,数据库的写入请求量大造成的性能和可用性方面的问题,要解决这些问题,你所采取的措施就是对数据进行分片。这样可以很好地分摊数据库的读写压力,也可以突破单机的存储瓶颈,而常见的一种方式是对数据库做“分库分表”。
- 如何对数据库做垂直拆分
分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均地分配到多个数据库节点或者多个表中。不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效地提升数据查询的性能。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。
垂直拆分的原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。举个形象的例子,就是在整理衣服的时候,将羽绒服、毛衣、T 恤分别放在不同的格子里。通过把不同的业务的数据分拆到不同的数据库节点上,这样一旦数据库发生故障时只会影响到某一个模块的功能,不会影响到整体功能,从而实现了数据层面的故障隔离。
在微博系统中有和用户相关的表,有和内容相关的表,有和关系相关的表,这些表都存储在主库中。在拆分后,我们期望用户相关的表分拆到用户库中,内容相关的表分拆到内容库中,关系相关的表分拆到关系库中。
对数据库进行垂直拆分是一种偏常规的方式,这种方式其实你会比较常用,不过拆分之后,虽然可以暂时缓解存储容量的瓶颈,但并不是万事大吉,因为数据库垂直拆分后依然不能解决某一个业务模块的数据大量膨胀的问题,一旦你的系统遭遇某一个业务库的数据量暴增,在这个情况下,你还需要继续寻找可以弥补的方式。比如微博关系量早已经过了千亿,单一的数据库或者数据表已经远远不能满足存储和查询的需求了,这个时候,你需要将数据拆分到多个数据库和数据表中,也就是对数据库和数据表做水平拆分了。
- 如何对数据库做水平拆分
和垂直拆分的关注点不同,垂直拆分的关注点在于业务相关性,而水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在数据的特点。拆分的规则有下面这两种:
- 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库,每个库是 64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值;对 64 取余,就得到了分表后的索引值。
另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。你知道在内容表里面有“创建时间”的字段,而我们也是按照时间来查看一个人发布的内容。我们可能会要看昨天的内容,也可能会看一个月前发布的内容,这时就可以按照创建时间的区间来分库分表,比如说可以把一个月的数据放入一张表中,这样在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。
一般来说,列表数据可以使用这种拆分方式,比如一个人一段时间的订单,一段时间发布的内容。但是这种方式可能会存在明显的热点,这很好理解嘛,你当然会更关注最近我买了什么,发了什么,所以查询的 QPS 也会更多一些,对性能有一定的影响。另外,使用这种拆分规则后,数据表要提前建立好,否则如果时间到了 2020 年元旦,DBA(Database Administrator,数据库管理员)却忘记了建表,那么 2020 年的数据就没有库表可写了,就会发生故障了。
数据库在分库分表之后,数据的访问方式也有了极大的改变,原先只需要根据查询条件到从库中查询数据即可,现在则需要先确认数据在哪一个库表中,再到那个库表中查询数据。这种复杂度也可以通过数据库中间件来解决。
- 解决分库分表引入的问题
-
分库分表引入的一个最大的问题就是引入了分库分表键,也叫做分区键,也就是我们对数据库做分库分表所依据的字段。针对这个问题,我们也会有一些相应的解决思路。比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户时,你可以按照昵称作为分区键再做一次拆分,但是这样会极大地增加存储成本,如果以后我们还需要按照注册时间来查询时要怎么办呢,再做一次拆分吗? 所以最合适的思路是你要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据,这个表也可以是分库分表的,也需要占用一定的存储空间,但是因为表中只有两个字段,所以相比重新做一次拆分还是会节省不少的空间的。
-
分库分表引入的另外一个问题是一些数据库的特性在实现时可能变得很困难。比如说多表的 JOIN 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了,不过好在我们对于 JOIN 的需求不高,即使有也一般是把两个表的数据取出后在业务代码里面做筛选,复杂是有一些,不过是可以实现的。再比如说在未分库分表之前查询数据总数时只需要在 SQL 中执行 count() 即可,现在数据被分散到多个库表中,我们可能要考虑其他的方案,比方说将计数的数据单独存储在一张表中或者记录在 Redis 里面。
总的来说,在面对数据库容量瓶颈和写并发量大的问题时,你可以采用垂直拆分和水平拆分来解决,不过你要注意,这两种方式虽然能够解决问题,但是也会引入诸如查询数据必须带上分区键,列表总数需要单独冗余存储等问题。而且,你需要了解的是在实现分库分表过程中,数据从单库单表迁移多库多表是一件即繁杂又容易出错的事情,而且如果我们初期没有规划得当,后面要继续增加数据库数或者表数时,我们还要经历这个迁移的过程。所以,对于分库分表的原则主要有以下几点:
- 如果在性能上没有瓶颈点那么就尽量不做分库分表;
- 如果要做,就尽量一次到位,比如说 16 库,每个库 64 表就基本能够满足为了几年内你的业务的需求。
- 很多的 NoSQL 数据库,例如 Hbase,MongoDB 都提供 auto sharding 的特性,如果你的团队内部对于这些组件比较熟悉,有较强的运维能力,那么也可以考虑使用这些 NoSQL 数据库替代传统的关系型数据库。
二 . 缓存篇
从整体上看,数据库分了主库和从库,数据也被切分到多个数据库节点上。但随着并发的增加,存储数据量的增多,数据库的磁盘 IO 逐渐成了系统的瓶颈,我们需要一种访问更快的组件来降低请求响应时间,提升整体系统性能。这时我们就会使用缓存。
缓存如何做到高可用
我们在 Web 层和数据库层之间增加了缓存层,请求会首先查询缓存,只有当缓存中没有需要的数据时才会查询数据库。在这里,你需要关注缓存命中率这个指标(缓存命中率 = 命中缓存的请求数 / 总请求数)。一般来说,在你的电商系统中,核心缓存的命中率需要维持在 99% 甚至是 99.9%,哪怕下降 1%,系统都会遭受毁灭性的打击。
假设系统的 QPS 是 10000/s,每次调用会访问 10 次缓存或者数据库中的数据,那么当缓存命中率仅仅减少 1%,数据库每秒就会增加 10000 * 10 * 1% = 1000 次请求。而一般来说我们单个 MySQL 节点的读请求量峰值就在 1500/s 左右,增加的这 1000 次请求很可能会给数据库造成极大的冲击。命中率仅仅下降 1% 造成的影响就如此可怕,更不要说缓存节点故障了。而图中单点部署的缓存节点就成了整体系统中最大的隐患,那我们要如何来解决这个问题,提升缓存的可用性呢?
常用的解决方案有客户端方案、中间代理层方案和服务端方案三大类:
- 客户端方案就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。
- 中间代理层方案是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。
- 其实你在一些组件中都会看到消息队列的影子:在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理这些任务;操作系统中,中断的下半部分也会使用工作队列来实现延后执行;我们在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个工作线程来处理。
1. 客户端方案
在客户端方案中,你需要关注缓存的写和读两个方面:写入数据时,需要把被写入缓存的数据分散到多个节点中,即进行数据分片;读数据时,可以利用多组的缓存来做容错,提升缓存系统的可用性。关于读数据,这里可以使用主从和多副本两种策略,两种策略是为了解决不同的问题而提出的。
我们可以使用 Hash 分片算法和一致性 Hash 分片算法来做数据的分片。另外,主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性。
2.中间代理层方案
虽然客户端方案已经能解决大部分的问题,但是只能在单一语言系统之间复用。例如微博使用 Java 语言实现了这么一套逻辑,我使用 PHP 就难以复用,需要重新写一套,很麻烦。而中间代理层的方案就可以解决这个问题。你可以将客户端解决方案的经验移植到代理层中,通过通用的协议(如 Redis 协议)来实现在其他语言中的复用。
如果你来自研缓存代理层,你就可以将客户端方案中的高可用逻辑封装在代理层代码里面,这样用户在使用你的代理层的时候就不需要关心缓存的高可用是如何做的,只需要依赖你的代理层就好了。除此以外,业界也有很多中间代理层方案,比如 Facebook 的Mcrouter,Twitter 的Twemproxy,豌豆荚的Codis。它们的原理基本上可以由一张图来概括:
看这张图你有什么发现吗? 所有缓存的读写请求都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用的逻辑,不同的开源中间代理层方案中使用的高可用策略各有不同。比如在 Twemproxy 中,Proxy 保证在某一个 Redis 节点挂掉之后会把它从集群中移除,后续的请求将由其他节点来完成;而 Codis 的实现略复杂,它提供了一个叫 Codis Ha 的工具来实现自动从节点提主节点,在 3.2 版本之后换做了 Redis Sentinel 方式,从而实现 Redis 节点的高可用。
3.服务端方案
Redis 在 2.4 版本中提出了 Redis Sentinel 模式来解决主从 Redis 部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性,整体的架构如下图所示:
Redis Sentinel 也是集群部署的,这样可以避免 Sentinel 节点挂掉造成无法自动故障恢复的问题,每一个 Sentinel 节点都是无状态的。在 Sentinel 中会配置 Master 的地址,Sentinel 会时刻监控 Master 的状态,当发现 Master 在配置的时间间隔内无响应,就认为 Master 已经挂了,Sentinel 会从从节点中选取一个提升为主节点,并且把所有其他的从节点作为新主的从节点。Sentinel 集群内部在仲裁的时候,会根据配置的值来决定当有几个 Sentinel 节点认为主挂掉可以做主从切换的操作,也就是集群内部需要对缓存节点的状态达成一致才行。
缓存穿透
对于缓存来说命中率是它的生命线。一般来说,我们的核心缓存的命中率要保持在 99% 以上,非核心缓存的命中率也要尽量保证在 90%,如果低于这个标准你可能就需要优化缓存的使用方式了。缓存穿透其实是指从缓存中没有查到数据,而不得不从后端系统(比如数据库)中查询的情况。不过少量的缓存穿透不可避免,对系统也是没有损害的,主要有几点原因:
- 一方面,互联网系统通常会面临极大数据量的考验,而缓存系统在容量上是有限的,不可能存储系统所有的数据,那么在查询未缓存数据的时候就会发生缓存穿透。
- 另一方面,互联网系统的数据访问模型一般会遵从“80/20 原则”。“80/20 原则”又称为帕累托法则,是意大利经济学家帕累托提出的一个经济学的理论。它是指在一组事物中最重要的事物通常只占 20%,而剩余的 80% 的事物确实不重要的。把它应用到数据访问的领域,就是我们会经常访问 20% 的热点数据,而另外的 80% 的数据则不会被经常访问。比如你买了很多衣服,很多书,但是其实经常穿的、经常看的可能也就是其中很小的一部分。
那么什么样的缓存穿透对系统有害呢?答案是大量的穿透请求超过了后端系统的承受范围造成了后端系统的崩溃。如果把少量的请求比作毛毛细雨,那么一旦变成倾盆大雨,引发洪水,冲倒房屋,肯定就不行了。产生这种大量穿透请求的场景有很多,接下来我就带你解析这几种场景以及相应的解决方案。
先来考虑这样一种场景:在你的电商系统的用户表中,我们需要通过用户 ID 查询用户的信息,缓存的读写策略采用 Cache Aside 策略。那么如果要读取一个用户表中未注册的用户,会发生什么情况呢?按照这个策略,我们会先读缓存再穿透读数据库。由于用户并不存在,所以缓存和数据库中都没有查询到数据,因此也就不会向缓存中回种数据(也就是向缓存中设置值的意思),这样当再次请求这个用户数据的时候还是会再次穿透到数据库。
那如何解决缓存穿透呢?一般来说我们会有两种解决方案:回种空值以及使用布隆过滤器。
回种空值
当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。所以这个方案,我建议你在使用的时候应该评估一下缓存容量是否能够支撑。如果需要大量的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时你可以考虑使用布隆过滤器。
使用布隆过滤器
这种算法由一个二进制数组和一个 Hash 算法组成。它的基本思路如下:
我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。
首先我们初始化一个很大的数组,比方说长度为 20 亿的数组,接下来我们选择一个 Hash 算法,然后我们将目前现有的所有用户的 ID 计算出 Hash 值并且映射到这个大数组中,映射位置的值设置为 1,其它值设置为 0。新注册的用户除了需要写入到数据库中之外,它也需要依照同样的算法更新布隆过滤器的数组中相应位置的值。那么当我们需要查询某一个用户的信息时,先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。
布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1) 是常量值。在空间上,相对于其他数据结构它也有很大的优势,比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。
总的来说,回种空值和布隆过滤器是解决缓存穿透问题的两种最主要的解决方案,但是它们也有各自的适用场景,并不能解决所有问题。比方说当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,我们把这个场景叫做“dog-pile effect”(狗桩效应)。这是典型的缓存并发穿透的问题,那么,我们如何来解决这个问题呢?解决狗桩效应的思路是尽量地减少缓存穿透后的并发,方案也比较简单:
-
在代码中控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。
-
通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库。
数据库是一个脆弱的资源,它无论是在扩展性、性能还是承担并发的能力上,相比缓存都处于绝对的劣势,所以我们解决缓存穿透问题的核心目标在于减少对于数据库的并发请求。了解了这个核心的思想,也许你还会在日常工作中找到其他更好的解决缓存穿透问题的方案。
4.消息队列篇
假设你的商城策划了一期秒杀活动,活动在第五天的 00:00 开始,仅限前 200 名,那么秒杀即将开始时,后台会显示用户正在疯狂地刷新 APP 或者浏览器来保证自己能够尽量早的看到商品。00:00 分秒杀活动准时开始,用户瞬间向电商系统请求生成订单,扣减库存,用户的这些写操作都是不经过缓存直达数据库的。1 秒钟之内,有 1 万个数据库连接同时达到,系统的数据库濒临崩溃,寻找能够应对如此高并发的写请求方案迫在眉睫。这时你想到了消息队列。
其实你在一些组件中都会看到消息队列的影子:
- 在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理这些任务; 操作系统中,中断的下半部分也会使用工作队列来实现延后执行; 我们在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个工作线程来处理。
那么我们如何用消息队列解决秒杀场景下的问题呢?接下来,我们结合具体的例子来看看消息队列在秒杀场景下起到的作用。在秒杀场景下高并发的写请求并不是持续的,也不是经常发生的,而只有在秒杀活动开始后的几秒或者十几秒时间内才会存在。为了应对这十几秒的瞬间写高峰花费几天甚至几周的时间来扩容数据库,再在秒杀之后花费几天的时间来做缩容,这无疑是得不偿失的。 * 所以我们的思路是:将秒杀请求暂存在消息队列中,然后业务服务器会响应用户“秒杀结果正在计算中”,释放了系统资源之后再处理其它用户的请求。我们会在后台启动若干个队列处理程序消费消息队列中的消息,再执行校验库存、下单等逻辑。因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的。而请求是可以在消息队列中被短暂地堆积,当库存被消耗完之后,消息队列中堆积的请求就可以被丢弃了。
这就是消息队列在秒杀系统中最主要的作用:削峰填谷,也就是说它可以削平短暂的流量高峰,虽说堆积会造成请求被短暂延迟处理,但是只要我们时刻监控消息队列中的堆积长度,在堆积量超过一定量时,增加队列处理机数量来提升消息的处理能力就好了,而且秒杀的用户对于短暂延迟知晓秒杀的结果也是有一定容忍度的。
其实在大量的写请求“攻击”你的电商系统的时候,消息队列除了发挥主要的削峰填谷的作用之外,还可以实现异步处理来简化秒杀请求中的业务流程,提升系统的性能。你想,在刚才提到的秒杀场景下,我们在处理购买请求时需要 500ms。这时你分析了一下整个的购买流程,发现这里面会有主要的业务逻辑,也会有次要的业务逻辑:比如说,主要的流程是生成订单、扣减库存;次要的流程可能是我们在下单购买成功之后会给用户发放优惠券,会增加用户的积分。
假如发放优惠券的耗时是 50ms,增加用户积分的耗时也是 50ms,那么如果我们将发放优惠券、增加积分的操作放在另外一个队列处理机中执行,那么整个流程就缩短到了 400ms,性能提升了 20%,处理这 1000 件商品的时间就变成了 400s。如果我们还是希望能在 50s 之内看到秒杀结果的话,只需要部署 8 个队列程序就好了。
除了异步处理和削峰填谷以外,消息队列在秒杀系统中起到的另一个作用是解耦合。比如数据团队对你说,在秒杀活动之后想要统计活动的数据,借此来分析活动商品的受欢迎程度、购买者人群的特点以及用户对于秒杀互动的满意程度等等指标。而我们需要将大量的数据发送给数据团队,那么要怎么做呢?
一个思路是:使用 HTTP 或者 RPC 的方式来同步地调用,也就是数据团队这边提供一个接口,我们实时将秒杀的数据推送给它,但是这样调用会有两个问题:
- 整体系统的耦合性比较强,当数据团队的接口发生故障时,会影响到秒杀系统的可用性。
- 当数据系统需要新的字段,就要变更接口的参数,那么秒杀系统也要随着一起变更。
这时,我们可以考虑使用消息队列降低业务系统和数据系统的直接耦合度。秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后数据团队再订阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时当数据系统需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。
异步处理、解耦合和削峰填谷是消息队列在秒杀系统设计中起到的主要作用,其中异步处理可以简化业务流程中的步骤,提升系统性能;削峰填谷可以削去到达秒杀系统的峰值流量,让业务逻辑的处理更加缓和;解耦合可以将秒杀系统和数据系统解耦开,这样两个系统的任何变更都不会影响到另一个系统,如果你的系统想要提升写入性能实现系统的低耦合,想要抵挡高并发的写流量,那么你就可以考虑使用消息队列来完成。
如何保证消息仅仅被消费一次
比如你的电商系统需要上一个新的红包功能:用户在购买一定数量的商品之后,由你的系统给用户发一个现金的红包鼓励用户消费。由于发放红包的过程不应该在购买商品的主流程之内,所以你考虑使用消息队列来异步处理。这时你发现了一个问题:如果消息在投递的过程中发生丢失,那么用户就会因为没有得到红包而投诉。相反,如果消息在投递的过程中出现了重复,你的系统就会因为发送两个红包而损失。
如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实主要存在三个场景:
- 消息从生产者写入到消息队列的过程;
- 消息在消息队列中的存储场景;
- 消息被消费者消费的过程。
- 在消息生产的过程中丢失消息
首先,消息的生产者一般是我们的业务服务器,消息队列是独立部署在单独的服务器上的。两者之间的网络虽然是内网但是也会存在抖动的可能,而一旦发生抖动,消息就有可能因为网络的错误而丢失。针对这种情况,我建议你采用的方案是消息重传。也就是当你发现发送超时后就将消息重新发一次,但也不能无限制地重传消息。一般来说,如果不是消息队列发生故障或者是到消息队列的网络断开了,重试 2~3 次就可以了。不过这种方案可能会造成消息的重复,从而在消费的时候重复消费同样的消息。比方说消息生产时由于消息队列处理慢或者网络的抖动,导致虽然最终写入消息队列成功但在生产端却超时了,生产者重传这条消息就会形成重复的消息,针对上面的例子,直观显示在你面前的就会是你收到了两个现金红包。那么消息发送到了消息队列之后是否就万无一失了呢?当然不是,在消息队列中消息仍然有丢失的风险。
- 在消息队列中丢失消息
拿 Kafka 举例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中,然后再找合适的时机刷新到磁盘上。不过如果发生机器掉电或者机器异常重启,Page Cache 中还没有来得及刷盘的消息就会丢失了。那么怎么解决呢?你可能会把刷盘的间隔设置很短或者设置累积一条消息就就刷盘,但这样频繁刷盘会对性能有比较大的影响,而且从经验来看,出现机器宕机或者掉电的几率也不高,所以我不建议你这样做。如果你的电商系统对消息丢失的容忍度很低,你可以考虑以集群方式部署 Kafka 服务,通过部署多个副本备份数据保证消息尽量不丢失。
- 在消费的过程中存在消息丢失的可能
还是以 Kafka 为例来说明。一个消费者消费消息的进度是记录在消息队列集群中的,而消费的过程分为三步:接收消息、处理消息、更新消费进度。
这里面接收消息和处理消息的过程都可能会发生异常或者失败,比如消息接收时网络发生抖动,导致消息并没有被正确的接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,这条失败的消息就永远不会被处理了,也可以认为是丢失了。
所以,在这里你需要注意的是,一定要等到消息接收和处理完成后才能更新消费进度,但是这也会造成消息重复的问题,比方说某一条消息在处理之后消费者恰好宕机了,那么因为没有更新消费进度,所以当这个消费者重启之后还会重复地消费这条消息。
想要完全的避免消息重复的发生是很难做到的,因为网络的抖动、机器的宕机和处理的异常都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使消费到了重复的消息,从消费的最终结果来看和只消费一次是等同的就好了,也就是保证在消息的生产和消费的过程是“幂等”的。
虽然我讲了很多应对消息丢失的方法,但并不是说消息丢失一定不能被接受,毕竟你可以看到在允许消息丢失的情况下,消息队列的性能更好,方案实现的复杂度也最低。比如像是日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发的丢失几条日志是可以接受的。所以方案设计看场景,这是一切设计的原则,你不能把所有的消息队列都配置成防止消息丢失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外的负担。
如何降低消息队列系统中消息的延迟?
在你的垂直电商项目中,你会在用户下单支付之后向消息队列里面发送一条消息,队列处理程序消费了消息后会增加用户的积分或者给用户发送优惠券。用户在下单之后,等待几分钟或者十几分钟拿到积分和优惠券是可以接受的,但是一旦消息队列出现大量堆积,用户消费完成后几小时还拿到优惠券,那就会有用户投诉了。
这时你要关注的就是消息队列中消息的延迟了,这其实是消费性能的问题,那么你要如何提升消费性能保证更短的消息延迟呢?在我看来,首先需要掌握如何来监控消息的延迟,因为有了数据之后你才可以知道目前的延迟数据是否满足要求,也可以评估优化之后的效果。然后你要掌握使用消息队列的正确姿势以及关注消息队列本身是如何保证消息尽快被存储和投递的。
如何监控消息延迟
监控消息的延迟有两种方式:使用消息队列提供的工具,通过监控消息的堆积来完成;通过生成监控消息的方式来监控消息的延迟情况。
Kafka 通过 JMX 暴露了消息堆积的数据,我在本地启动了一个 console consumer,然后使用 jconsole 连接 consumer 就可以看到 consumer 的堆积数据了(就是下图中红框里的数据)。这些数据你可以写代码来获取,这样也可以方便地输出到监控系统中。
想要减少消息的处理延迟,我们需要在消费端和消息队列两个层面来完成。在消费端的目标是提升消费者的消息处理能力,你能做的是:优化消费代码提升性能;增加消费者的数量(这个方式比较简单)。不过第二种方式会受限于消息队列的实现。如果消息队列使用的是 Kafka 就无法通过增加消费者数量的方式来提升消息处理能力。
虽然不能增加 consumer,但你可以在一个 consumer 中提升处理消息的并行度,所以可以考虑使用多线程的方式来增加处理能力:你可以预先创建一个或者多个线程池,在接收到消息之后把消息丢到线程池中来异步地处理,这样,原本串行的消费消息的流程就变成了并行的消费,可以提高消息消费的吞吐量,在并行处理的前提下,我们就可以在一次和消息队列的交互中多拉取几条数据,然后分配给多个线程来处理。
其实队列是一种常用的组件,只要涉及到队列,任务的堆积就是一个不可忽视的问题,我遇到过的很多故障都是源于此。比如前一段时间处理的一个故障,前期只是因为数据库性能衰减有少量的慢请求,结果这些慢请求占满了 Tomcat 线程池,导致整体服务的不可用。如果我们能对 Tomcat 线程池的任务堆积情况有实时地监控,或者说对线程池有一些保护策略,比方说线程全部使用之后丢弃请求,也许就会避免故障的发生。在此,我希望你在实际的工作中能够引以为戒,只要有队列就要监控它的堆积情况,把问题消灭在萌芽之中。