为什么要学习高并发系统设计(高并发系统建设经验总结)
为什么要学习高并发系统设计(高并发系统建设经验总结)MySQL 默认的主从复制是异步的,如果在主库插入数据后马上去从库查询,可能会发生查不到的情况。正常情况下主从复制会存在毫秒级的延迟,在 DB 负载较高的情况下可能存在秒级延迟甚至更久,但即使是毫秒级的延迟,对于实时性要求较高的业务来说也是不可忽视的。所以在一些关键的查询场景,我们会将查询请求绑定到主库来避免主从延迟的问题。关于主从延迟的优化网上也有不少的文章分享,这里就不再赘述。这种模式的好处是简单,几乎没有代码改造成本或只有少量的代码改造成本,只需要配置数据库主从即可。缺点也是同样明显的:多活可以分为同城多活、异地多活等等,实现方式也有多种,比如阿里使用的单元化方案,饿了么使用的是多中心的方案,关于多活的实现可以参考:饿了么多活实现分享。当时做多活的主要出发点是保证系统的高可用性,避免单 IDC 的单点故障问题,同时由于每个机房的流量都变成了总流量的 1/N,也变相提升了系统容量,在高
作者:listenzhang,腾讯 PCG 后台开发工程师
前言早期从事运单系统的开发和维护工作,从最早的日均百万单,到日均千万单,业务的快速发展再加上外卖业务的特点是,业务量集中在午高峰和晚高峰两个高峰期,所以高峰期并发请求量也是水涨船高,每天都要面对高并发的挑战。拿运单系统来举例,日常午高峰核心查询服务的 QPS 在 20 万以上,Redis 集群的 QPS 更是在百万级,数据库 QPS 也在 10 万级以上,TPS 在 2 万以上。
在这么大的流量下,主要的工作也是以围绕如何建设系统的稳定性和提升容量展开,下面主要从基础设施、数据库、架构、应用、规范这几方面谈谈如何建设高并发的系统。以下都是我个人这几年的一些经验总结,架构没有银弹,因此也称不上是最佳实践,仅供参考。
基础设施在分层架构中,最底层的就是基础设施。基础设置一般来说包含了物理服务器、IDC、部署方式等等。就像一个金字塔,基础设施就是金字塔的底座,只有底座稳定了,上层才能稳定。
异地多活多活可以分为同城多活、异地多活等等,实现方式也有多种,比如阿里使用的单元化方案,饿了么使用的是多中心的方案,关于多活的实现可以参考:饿了么多活实现分享。当时做多活的主要出发点是保证系统的高可用性,避免单 IDC 的单点故障问题,同时由于每个机房的流量都变成了总流量的 1/N,也变相提升了系统容量,在高并发的场景下可以抗更多的流量。下图是活的整体架构,来源于上面多活实现的分享文章中。
数据库数据库是整个系统最重要的组成部分之一,在高并发的场景下很大一部分工作是围绕数据库展开的,主要需要解决的问题是如何提升数据库容量。
读写分离互联网的大部分业务特点是读多写少,因此使用读写分离架构可以有效降低数据库的负载,提升系统容量和稳定性。核心思路是由主库承担写流量,从库承担读流量,且在读写分离架构中一般都是 1 主多从的配置,通过多个从库来分担高并发的查询流量。比如现在有 1 万 QPS 的以及 1K 的 TPS,假设在 1 主 5 从的配置下,主库只承担 1K 的 TPS,每个从库承担 2K 的 QPS,这种量级对 DB 来说是完全可接受的,相比读写分离改造前,DB 的压力明显小了许多。
这种模式的好处是简单,几乎没有代码改造成本或只有少量的代码改造成本,只需要配置数据库主从即可。缺点也是同样明显的:
主从延迟MySQL 默认的主从复制是异步的,如果在主库插入数据后马上去从库查询,可能会发生查不到的情况。正常情况下主从复制会存在毫秒级的延迟,在 DB 负载较高的情况下可能存在秒级延迟甚至更久,但即使是毫秒级的延迟,对于实时性要求较高的业务来说也是不可忽视的。所以在一些关键的查询场景,我们会将查询请求绑定到主库来避免主从延迟的问题。关于主从延迟的优化网上也有不少的文章分享,这里就不再赘述。
从库的数量是有限的一个主库能挂载的从库数量是很有限的,没办法做到无限的水平扩展。从库越多,虽然理论上能承受的 QPS 就越高,但是从库过多会导致主库主从复制 IO 压力更大,造成更高的延迟,从而影响业务,所以一般来说只会在主库后挂载有限的几个从库。
无法解决 TPS 高的问题从库虽然能解决 QPS 高的问题,但没办法解决 TPS 高的问题,所有的写请求只有主库能处理,一旦 TPS 过高,DB 依然有宕机的风险。
分库分表当读写分离不能满足业务需要时,就需要考虑使用分库分表模式了。当确定要对数据库做优化时,应该优先考虑使用读写分离的模式,只有在读写分离的模式已经没办法承受业务的流量时,我们才考虑分库分表的模式。分库分表模式的最终效果是把单库单表变成多库多表,如下图。
首先来说下分表,分表可以分为垂直拆分和水平拆分。垂直拆分就是按业务维度拆,假设原来有张订单表有 100 个字段,可以按不同的业务纬度拆成多张表,比如用户信息一张表,支付信息一张表等等,这样每张表的字段相对来说都不会特别多。
水平拆分是把一张表拆分成 N 张表,比如把 1 张订单表,拆成 512 张订单子表。
在实践中可以只做水平拆分或垂直拆分,也可以同时做水平及垂直拆分。
说完了分表,那分库是什么呢?分库就是把原来都在一个 DB 实例中的表,按一定的规则拆分到 N 个 DB 实例中,每个 DB 实例都会有一个 master,相当于是多 mater 的架构,同时为了保证高可用性,每个 master 至少要有 1 个 slave,来保证 master 宕机时 slave 能及时顶上,同时也能保证数据不丢失。拆分完后每个 DB 实例中只会有部分表。
由于是多 master 的架构,分库分表除了包含读写分离模式的所有优点外,还可以解决读写分离架构中无法解决的 TPS 过高的问题,同时分库分表理论上是可以无限横向扩展的,也解决了读写分离架构下从库数量有限的问题。当然在实际的工程实践中一般需要提前预估好容量,因为数据库是有状态的,如果发现容量不足再扩容是非常麻烦的,应该尽量避免。
在分库分表的模式下可以通过不启用查询从库的方式来避免主从延迟的问题,也就是说读写都在主库,因为在分库后,每个 master 上的流量只占总流量的 1/N,大部分情况下能扛住业务的流量,从库只作为 master 的备份,在主库宕机时执行主从切换顶替 master 提供服务使用。说完了好处,再来说说分库分表会带来的问题,主要有以下几点:
改造成本高分库分表一般需要中间件的支持,常见的模式有两种:客户端模式和代理模式。客户端模式会通过在服务上引用 client 包的方式来实现分库分表的逻辑,比较有代表的是开源的 sharding JDBC。代理模式是指所有的服务不是直接连接 MySQL,而是通过连接代理,代理再连接到 MySQL 的方式,代理需要实现 MySQL 相关的协议。
两种模式各有优劣势,代理模式相对来说会更复杂,但是因为多了一层代理,在代理这层能做更多的事情,也比较方便升级,而且通过代理连接数据库,也能保证数据库的连接数稳定。使用客户端模式好处是相对来说实现比较简单,无中间代理,理论上性能也会更好,但是在升级的时候需要业务方改造代码,因此升级会比代理模式更困难。
事务问题在业务中我们会使用事务来处理多个数据库操作,通过事务的 4 个特性——一致性、原子性、持久性、隔离性来保证业务流程的正确性。在分库分表后,会将一张表拆分成 N 张子表,这 N 张子表可能又在不同的 DB 实例中,因此虽然逻辑上看起来还是一张表,但其实已经不在一个 DB 实例中了,这就造成了无法使用事务的问题。
最常见的就是在批量操作中,在分库分表前我们可以同时把对多个订单的操作放在一个事务中,但在分库分表后就不能这么干了,因为不同的订单可能属于不同用户,假设我们按用户来分库分表,那么不同用户的订单表位于不同的 DB 实例中,多个 DB 实例显然没办法使用一个事务来处理,这就需要借助一些其他的手段来解决这个问题。在分库分表后应该要尽量避免这种跨 DB 实例的操作,如果一定要这么使用,优先考虑使用补偿等方式保证数据最终一致性,如果一定要强一致性,常用的方案是通过分布式事务的方式。
无法支持多维度查询分库分表一般只能按 1-2 个纬度来分,这个维度就是所谓的sharding key。常用的维度有用户、商户等维度,如果按用户的维度来分表,最简单的实现方式就是按用户 ID 来取模定位到在哪个分库哪个分表,这也就意味着之后所有的读写请求都必须带上用户 ID,但在实际业务中不可避免的会存在多个维度的查询,不一定所有的查询都会有用户 ID,这就需要我们对系统进行改造。
为了能在分库分表后也支持多维度查询,常用的解决方案有两种,第一种是引入一张索引表,这张索引表是没有分库分表的,还是以按用户 ID 分库分表为例,索引表上记录各种维度与用户 ID 之间的映射关系,请求需要先通过其他维度查询索引表得到用户 ID,再通过用户 ID 查询分库分表后的表。这样,一来需要多一次 IO,二来索引表由于是没有分库分表的,很容易成为系统瓶颈。
第二种方案是通过引入NoSQL的方式,比较常见的组合是ES MySQL,或者HBase MySQL的组合等,这种方案本质上还是通过 NoSQL 来充当第一种方案中的索引表的角色,但是相对于直接使用索引表来说,NoSQL具有更好的水平扩展性和伸缩性,只要设计得当,一般不容易成为系统的瓶颈。
数据迁移分库分表一般是需要进行数据迁移的,通过数据迁移将原有的单表数据迁移到分库分表后的库表中。数据迁移的方案常见的有两种,第一种是停机迁移,顾名思义,这种方式简单粗暴,好处是能一步到位,迁移周期短,且能保证数据一致性,坏处是对业务有损,某些关键业务可能无法接受几分钟或更久的停机迁移带来的业务损失。
另外一种方案是双写,这主要是针对新增的增量数据,存量数据可以直接进行数据同步,关于如何进行双写迁移网上已经有很多分享了,这里也就不赘述,核心思想是同时写老库和新库。双写的好处是对业务的影响小,但也更复杂,迁移周期更长,容易出现数据不一致问题,需要有完整的数据一致性保证方案支持。
小结读写分离模式和分库分表模式推荐优先使用读写分离模式,只有在不满业务需求的情况才才考虑使用分库分表模式。原因是分库分表模式虽然能显著提升数据库的容量,但会增加系统复杂性,而且由于只能支持少数的几个维度读写,从某种意义上来说对业务系统也是一种限制,因此在设计分库分表方案的时候需要结合具体业务场景,更全面的考虑。
架构在高并发系统建设中,架构同样也是非常重要的,这里分享缓存、消息队列、资源隔离等等模式的一些经验。
缓存在高并发的系统架构中缓存是最有效的利器,可以说没有之一。缓存的最大作用是可以提升系统性能,保护后端存储不被大流量打垮,增加系统的伸缩性。缓存的概念最早来源于 CPU 中,为了提高 CPU 的处理速度,引入了 L1、L2、L3 三级高速缓存来加速访问,现在系统中使用的缓存也是借鉴了 CPU 中缓存的做法。
缓存是个非常大的话题,可以单独写一本书也毫不夸张,在这里总结一下我个人在运单系统设计和实现缓存的时候遇到的一些问题和解决方案。缓存主要分为本地缓存和分布式缓存,本地缓存如Guava Cache、EHCache等,分布式缓存如Redis、Memcached等,在运单系统中使用的主要以分布式缓存为主。
如何保证缓存与数据库的数据一致性首先是如何保证缓存与数据库的数据一致性问题,基本在使用缓存的时候都会遇到这个问题,同时这也是个高频的面试题。在我负责的运单系统中使用缓存这个问题就更突出了,首先运单是会频繁更新的,并且运单系统对数据一致性的要求是非常高的,基本不太能接受数据不一致,所以不能简单的通过设置一个过期时间的方式来失效缓存。
关于缓存读写的模式推荐阅读耗子叔的文章:缓存更新的套路,里面总结了几种常用的读写缓存的套路,我在运单系统中的缓存读写模式也是参考了文章中的Write through模式,通过伪代码的方式大概是这样的:
lock(运单ID) {
//...
// 删除缓存
deleteCache();
// 更新DB
updateDB();
// 重建缓存
reloadCache()
}
既然是Write through模式,那对缓存的更新就是在写请求中进行的。首先为了防止并发问题,写请求都需要加分布式锁,锁的粒度是以运单 ID 为 key,在执行完业务逻辑后,先删除缓存,再更新 DB,最后再重建缓存,这些操作都是同步进行的,在读请求中先查询缓存,如果缓存命中则直接返回,如果缓存不命中则查询 DB,然后直接返回,也就是说在读请求中不会操作缓存,这种方式把缓存操作都收敛在写请求中,且写请求是加锁的,有效防止了读写并发导致的写入脏缓存数据的问题。
缓存数据结构的设计缓存要避免大 key 和热 key 的问题。举个例子,如果使用redis中的hash数据结构,那就比普通字符串类型的 key 更容易有大 key 和热 key 问题,所以如果不是非要使用hash的某些特定操作,可以考虑把hash拆散成一个一个单独的 key/value 对,使用普通的string类型的 key 存储,这样可以防止hash元素过多造成的大 key 问题,同时也可以避免单hash key过热的问题。
读写性能关于读写性能主要有两点需要考虑,首先是写性能,影响写性能的主要因素是 key/value 的数据大小,比较简单的场景可以使用JSON的序列化方式存储,但是在高并发场景下使用 JSON 不能很好的满足性能要求,而且也比较占存储空间,比较常见的替代方案有protobuf、thrift等等,关于这些序列化/反序列化方案网上也有一些性能对比,参考thrift-protobuf-compare - Benchmarking.wiki。
读性能的主要影响因素是每次读取的数据包的大小。在实践中推荐使用redis pipeline 批量操作的方式,比如说如果是字符串类型的 key,那就是pipeline mget的方式,假设一次mget10 个 key,100 个mget为一批 pipeline,那一次网络 IO 就可以查询 1000 个缓存 key,当然这里具体一批的数量要看缓存 key 的数据包大小,没有统一的值。
适当冗余适当冗余的意思是说我们在设计对外的业务查询接口的时候,可以适当的做一些冗余。这个经验是来自于当时我们在设计运单系统对外查询接口的时候,为了追求通用性,将接口的返回值设计成一个大对象,把运单上的所有字段都放在了这个大对象里面直接对外暴露了,这样的好处是不需要针对不同的查询方开发不同的接口了,反正字段就在接口里了,要什么就自己取。
这么做一开始是没问题的,但到我们需要对查询接口增加缓存的时候发现,由于所有业务方都通过这一个接口查询运单数据,我们没办法知道他们的业务场景,也就不知道他们对接口数据一致性的要求是怎么样的,比如能否接受短暂的数据一致性,而且我们也不知道他们具体使用了接口中的哪些字段,接口中有些字段是不会变的,有些字段是会频繁变更的,针对不同的更新频率其实可以采用不同的缓存设计方案,但很可惜,因为我们设计接口的时候过于追求通用性,在做缓存优化的时候就非常麻烦,只能按最坏的情况打算,也就是所有业务方都对数据一致性要求很高来设计方案,导致最后的方案在数据一致性这块花了大量的精力。
如果我们一开始设计对外查询接口的时候能做一些适当的冗余,区分不同的业务场景,虽然这样势必会造成有些接口的功能是类似的,但在加缓存的时候就能有的放矢,针对不同的业务场景设计不同的方案,比如关键的流程要注重数据一种的保证,而非关键场景则允许数据短暂的不一致来降低缓存实现的成本。同时在接口中最好也能将会更新的字段和不会更新的字段做一定的区分,这样在设计缓存方案的时候,针对不会更新的字段,可以设置一个较长的过期时间,而会更新的字段,则只能设置较短的过期时间,并且需要做好缓存更新的方案设计来保证数据一致性。
消息队列在高并发系统的架构中,消息队列(MQ)是必不可少的,当大流量来临时,我们通过消息队列的异步处理和削峰填谷的特性来增加系统的伸缩性,防止大流量打垮系统,此外,使用消息队列还能使系统间达到充分解耦的目的。
消息队列的核心模型由生产者(Producer)、消费者(Consumer)和消息中间件(Broker)组成。目前业界常用的开源解决方案有ActiveMQ、RabbitMQ、Kafka、RocketMQ和近年比较火的Pulsar,关于各种消息中间件的对比可以参考文章:消息队列背后的设计思想。
使用消息队列后,可以将原本同步处理的请求,改为通过消费 MQ 消息异步消费,这样可以减少系统处理的压力,增加系统吞吐量,关于如何使用消息队列有许多的分享的文章,这里我的经验是在考虑使用消息队列时要结合具体的业务场景来决定是否引入消息队列,因为使用消息队列后其实是增加了系统的复杂性的,原来通过一个同步请求就能搞定的事情,需要引入额外的依赖,并且消费消息是异步的,异步天生要比同步更复杂,还需要额外考虑消息乱序、延迟、丢失等问题,如何解决这些问题又是一个很大话题,天下没有免费的午餐,做任何架构设计是一个取舍的过程,需要仔细考虑得失后再做决定。
服务治理服务治理是个很大的话题,可以单独拿出来说,在这里我也把它归到架构中。服务治理的定义是
一般指独立于业务逻辑之外,给系统提供一些可靠运行的系统保障措施。
常见的保障措施包括服务的注册发现、可观测性(监控)、限流、超时、熔断等等,在微服务架构中一般通过服务治理框架来完成服务治理,开源的解决方案包括Spring Cloud、Dubbo等。
在高并发的系统中,服务治理是非常重要的一块内容,相比于缓存、数据库这些大块的内容,服务治理更多的是细节,比如对接口的超时设置到底是 1 秒还是 3 秒,怎么样做监控等等,有句话叫细节决定成败,有时候就是因为一个接口的超时设置不合理而导致大面积故障的事情,我曾经也是见识过的,特别是在高并发的系统中,一定要注意这些细节。
超时对于超时的原则是:一切皆有超时。不管是 RPC 调用、Redis 操作、消费消息/发送消息、DB 操作等等,都要有超时。之前就遇到过依赖了外部组件,但是没有设置合理的超时,当外部依赖出现故障时,把服务所有的线程全部阻塞导致资源耗尽,无法响应外部请求,从而引发故障,这些都是“血”的教训。
除了要设置超时,还要设置合理的超时也同样重要,像上面提到的故障即使设置了超时,但是超时太久的话依然会因为外部依赖故障而把服务拖垮。如何设置一个合理的超时是很有讲究的,可以从是否关键业务场景、是否强依赖等方面去考虑,没有什么通用的规则,需要结合具体的业务场景来看。比如在一些 C 端展示接口中,设置 1 秒的超时似乎没什么问题,但在一些对性能非常敏感的场景下 1 秒可能就太久了,总之,需要结合具体的业务场景去设置,但无论怎么样,原则还是那句话:一切皆有超时。
监控监控就是系统的眼睛,没有监控的系统就像一个黑盒,从外部完全不知道里面的运行情况,我们就无法管理和运维这个系统。所以,监控系统是非常重要的。系统的可观测性主要包含三个部分——logging、tracing、metrics。主要是使用的自研的监控系统,不得不说真的是非常的好用,具体的介绍可以参考:饿了么 EMonitor 演进史。在建设高并发系统时,我们一定要有完善的监控体系,包括系统层面的监控(CPU、内存、网络等)、应用层面的监控(JVM、性能等)、业务层面的监控(各种业务曲线等)等,除了监控还要有完善的报警,因为不可能有人 24 小时盯着监控,一旦有什么风险一定要报警出来,及时介入,防范风险于未然。
熔断在微服务框架中一般都会内置熔断的特性,熔断的目的是为了在下游服务出故障时保护自身服务。熔断的实现一般会有一个断路器(Crit Breaker),断路器会根据接口成功率/次数等规则来判断是否触发熔断,断路器会控制熔断的状态在关闭-打开-半打开中流转。熔断的恢复会通过时间窗口的机制,先经历半打开状态,如果成功率达到阈值则关闭熔断状态。
如果没有什么特殊需求的话在业务系统中一般是不需要针对熔断做什么的,框架会自动打开和关闭熔断开关。可能需要注意的点是要避免无效的熔断,什么是无效的熔断呢?在以前碰到过一个故障,是服务的提供方在一些正常的业务校验中抛出了不合理的异常(比如系统异常),导致接口熔断影响正常业务。所以我们在接口中抛出异常或者返回异常码的时候一定要区分业务和系统异常,一般来说业务异常是不需要熔断的,如果是业务异常而抛出了系统异常,会导致被熔断,正常的业务流程就会受到影响。
降级降级不是一种具体的技术,更像是一种架构设计的方法论,是一种丢卒保帅的策略,核心思想就是在异常的情况下限制自身的一些能力,来保证核心功能的可用性。降级的实现方式有许多,比如通过配置、开关、限流等等方式。降级分为主动降级和被动降级。
在电商系统大促的时候会把一些非核心的功能暂时关闭,来保证核心功能的稳定性,或者当下游服务出现故障且短时间内无法恢复时,为了保证自身服务的稳定性而把下游服务降级,这些都是主动降级。
被动降级指的是,比如调用了下游一个接口,但是接口超时了,这个时候为了让业务流程能继续执行下去,一般会选择在代码中catch异常,打印一条错误日志,然后继续执行业务逻辑,这种降级是被动的。
在高并发的系统中做好降级是非常重要的。举个例子来说,当请求量很大的时候难免有超时,如果每次超时业务流程都中断了,那么会大大影响正常业务,合理的做法是我们应该仔细区分强弱依赖,对于弱依赖采用被动降级的降级方式,而对于强依赖是不能进行降级的。降级与熔断类似,也是对自身服务的保护,避免当外部依赖故障时拖垮自身服务,所以,我们要做好充分的降级预案。
限流关于限流的文章和介绍网上也有许多,具体的技术实现可以参考网上文章。关于限流我个人的经验是在设置限流前一定要通过压测等方式充分做好系统容量的预估,不要拍脑袋,限流一般来说是有损用户体验的,应该作为一种兜底手段,而不是常规手段。
资源隔离资源隔离有各种类型,物理层面的服务器资源、中间件资源,代码层面的线程池、连接池,这些都可以做隔离。这里介绍的资源隔离主要是应用部署层面的,比如Set化等等。上文提到的异地多活也算是 Set 化的一种。
负责运单系统的期间也做过一些类似的资源隔离上的优化。背景是当时出遇到过一个线上故障,原因是某服务部署的服务器都在一个集群,没有按流量划分各自单独的集群,导致关键业务和非关键业务流量互相影响而导致的故障。因此,在这个故障后我也是决定对服务器做按集群隔离部署,隔离的维度主要是按业务场景区分,分为关键集群、次关键集群和非关键集群三类,这样能避免关键和非关键业务互相影响。
小结在架构方面,我个人也不是专业的架构师,也是一直在学习相关技术和方法论,上面介绍的很多技术和架构设计模式都是在工作中边学习边实践。如果说非要总结一点经验心得的话,我觉得是注重细节。个人认为架构不止高大上的方法论,技术细节也是同样重要的,正所谓细节决定成败,有时候忘记设置一个小小的超时,可能导致整个系统的崩溃。
应用在高并发的系统中,在应用层面能做的优化也是非常多的,这部分主要分享关于补偿、幂等、异步化、预热等这几方面的优化。
补偿在微服务架构下,会按各业务领域拆分不同的服务,服务与服务之前通过 RPC 请求或 MQ 消息的方式来交互,在分布式环境下必然会存在调用失败的情况,特别是在高并发的系统中,由于服务器负载更高,发生失败的概率会更大,因此补偿就更为重要。常用的补偿模式有两种:定时任务模式或者消息队列模式。
定时任务模式定时任务补偿的模式一般是需要配合数据库的,补偿时会起一个定时任务,定时任务执行的时候会扫描数据库中是否有需要补偿的数据,如果有则执行补偿逻辑,这种方案的好处是由于数据都持久化在数据库中了,相对来说比较稳定,不容易出问题,不足的地方是因为依赖了数据库,在数据量较大的时候,会对数据库造成一定的压力,而且定时任务是周期性执行的,因此一般补偿会有一定的延迟。
消息队列模式消息队列补偿的模式一般会使用消息队列中延迟消息的特性。如果处理失败,则发送一个延迟消息,延迟 N 分钟/秒/小时后再重试,这种方案的好处是比较轻量级,除了 MQ 外没有外部依赖,实现也比较简单,相对来说也更实时,不足的地方是由于没有持久化到数据库中,有丢失数据的风险,不够稳定。因此,我个人的经验是在关键链路的补偿中使用定时任务的模式,非关键链路中的补偿可以使用消息队列的模式。除此之外,在补偿的时候还有一个特别重要的点就是幂等性设计。
幂等幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同,体现在业务上就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为发起多次而产生副作用。在分布式系统中发生系统错误是在所难免的,当发生错误时,会使用重试、补偿等手段来提高容错性,在高并发的系统中发生系统错误的概率就更高了,所以这时候接口幂等就非常重要了,可以防止多次请求而引起的副作用。
幂等的实现需要通过一个唯一的业务 ID 或者 Token 来实现,一般的流程是先在 DB 或者缓存中查询唯一的业务 ID 或者 token 是否存在,且状态是否为已处理,如果是则表示是重复请求,那么我们需要幂等处理,即不做任何操作,直接返回即可。
在做幂等性设计的时候需要注意的是并不是所有的场景都要做幂等,比如用户重复转账、提现等等,因为幂等会让外部系统的感知是调用成功了,并没有阻塞后续流程,但其实我们系统内部是没有做任何操作的,类似上面提到的场景,会让用户误以为操作已成功。所以说要仔细区分需要幂等的业务场景和不能幂等的业务场景,对于不能幂等的业务场景还是需要抛出业务异常或者返回特定的异常码来阻塞后续流程,防止引发业务问题。
异步化上文提到的消息队列也是一种异步化,除了依赖外部中间件,在应用内我们也可以通过线程池、协程的方式做异步化。
关于线程池的实现原理,拿 Java 中线程池的模型来举例,核心是通过任务队列和复用线程的方式相配合来实现的,网上关于这些分享的文章也很多。在使用线程池或者协程等类似技术的时候,我个人的经验是有以下两点是需要特别注意的:
关键业务场景需要配合补偿我们都知道,不管是线程池也好,协程也好,都是基于内存的,如果服务器意外宕机或者重启,内存中的数据是会丢失的,而且线程池在资源不足的时候也会拒绝任务,所以在一些关键的业务场景中如果使用了线程池等类似的技术,需要配合补偿一块使用,避免内存中数据丢失造成的业务影响。在我维护的运单系统中有一个关键的业务场景是入单,简单来说就是接收上游请求,在系统中生成运单,这是整个物流履约流量的入口,是特别关键的一个业务场景。
因为生成运单的整个流程比较长,依赖外部接口有 10 几个,所以当时为了追求高性能和吞吐率,设计成了异步的模式,也就是在线程池中处理,同时为了防止数据丢失,也做了完善的补偿措施,这几年时间入单这块基本没有出过问题,并且由于采用了异步的设计,性能非常好,那我们具体是怎么做的呢。
总的流程是在接收到上游的请求后,第一步是将所有的请求参数落库,这一步是非常关键的,如果这一步失败,那整个请求就失败了。在成功落库后,封装一个 Task 提交到线程池中,然后直接对上游返回成功。后续的所有处理都是在线程池中进行的,此外,还有一个定时任务会定时补偿,补偿的数据源就是在第一步中落库的数据,每一条落库的记录会有一个 flag 字段来表示处理状态,如果发现是未处理或者处理失败,则通过定时任务再触发补偿逻辑,补偿成功后再将 flag 字段更新为处理成功。
做好监控在微服务中像 RPC 接口调用、MQ 消息消费,包括中间件、基础设施等的监控,这些基本都会针对性的做完善的监控,但是类似像线程池一般是没有现成监控的,需要使用方自行实现上报打点监控,这点很容易被遗漏。我们知道线程池的实现是会有内存队列的,而我们也一般会对内存队列设置一个最大值,如果超出了最大值可能会丢弃任务,这时候如果没有监控是发现不了类似的问题的,所以,使用线程池一定要做好监控。那么线程池有哪些可以监控的指标呢,按我的经验来说,一般会上报线程池的活跃线程数以及工作队列的任务个数,这两个指标我认为是最重要的,其他的指标就见仁见智了,可以结合具体业务场景来选择性上报。
预热Warm Up。当系统长期处于低水位的情况下,流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过"冷启动",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮。
参考网上的定义,说白了,就是如果服务一直在低水位,这时候突然来一波高并发的流量,可能会一下子把系统打垮。系统的预热一般有 JVM 预热、缓存预热、DB 预热等,通过预热的方式让系统先“热”起来,为高并发流量的到来做好准备。预热实际应用的场景有很多,比如在电商的大促到来前,我们可以把一些热点的商品提前加载到缓存中,防止大流量冲击 DB,再比如 Java 服务由于 JVM 的动态类加载机制,可以在启动后对服务做一波压测,把类提前加载到内存中,同时还有可以提前触发 JIT 编译、Code cache 等等好处。
还有一种预热的思路是利用业务的特性做一些预加载,比如我们在维护运单系统的时候做过这样一个优化,在一个正常的外卖业务流程中是用户下单后到用户交易系统生成订单,然后经历支付->商家接单->请求配送这样一个流程,所以说从用户下单到请求配送这之间有秒级到分钟级的时间差,我们可以通过感知用户下单的动作,利用这时间差来提前加载一些数据。
这样在实际请求到来的时候只需要到缓存中获取即可,这对于一些比较耗时的操作提升是非常大的,之前我们利用这种方式能提升接口性能 50%以上。当然有个点需要注意的就是如果对于一些可能会变更的数据,可能就不适合预热,因为预热后数据存在缓存中,后面就不会再去请求接口了,这样会导致数据不一致,这是需要特别注意的。
小结在做高并发系统设计的时候我们总是会特别关注架构、基础设施等等,这些的确非常重要,但其实在应用层面能做的优化也是非常多的,而且成本会比架构、基础设施的架构优化低很多。很多时候在应用层面做的优化需要结合具体的业务场景,利用特定的业务场景去做出合理的设计,比如缓存、异步化,我们就需要思考哪些业务场景能缓存,能异步化,哪些就是需要同步或者查询 DB,一定要结合业务才能做出更好的设计和优化。
规范这是关于建设高并发系统经验分享的最后一个部分了,但我认为规范的重要性一点都不比基础设施、架构、数据库、应用低,可能还比这些都更重要。根据二八定律,在软件的整个生命周期中,我们花了 20%时间创造了系统,但要花 80%的时间来维护系统,这也让我想起来一句话,有人说代码主要是给人读的,顺便给机器运行,其实都是体现了可维护性的重要性。
在我们使用了高大上的架构、做了各种优化之后,系统确实有了一个比较好的设计,但问题是怎么在后续的维护过程中防止架构腐化呢,这时候就需要规范了。
规范包括代码规范、变更规范、设计规范等等,当然这里我不会介绍如何去设计这些规范,我更想说的是我们一定要重视规范,只有在有了规范之后,系统的可维护性才能有保证。根据破窗理论,通过各种规范我们尽量不让系统有第一扇破窗产生。
总结说了这么多关于设计、优化的方法,最后想再分享两点。
第一点就是有句著名的话——“过早优化是万恶之源”,个人非常认同,我做的所有这些设计和优化,都是在系统遇到实际的问题或瓶颈的时候才做的,切忌不要脱离实际场景过早优化,不然很可能做无用功甚至得不偿失。
第二点是在设计的时候要遵循KISS 原则,也就是 Keep it simple stupid。简单意味着维护性更高,更不容易出问题,正所谓大道至简,或许就是这个道理。
以上这些都是我在工作期间维护高并发系统的一些经验总结,鉴于篇幅和个人技术水平原因,可能有些部分没有介绍的特别详细和深入,算是抛砖引玉吧。如果有什么说的不对的地方也欢迎指出,同时也欢迎交流和探讨。