Spiga

分布式架构之性能设计

2025-09-06 23:03:29

一、缓存

基本上来说,在分布式系统中最耗性能的地方就是最后端的数据库了。

一般来说,只要小心维护好,数据库四种操作(select、update、insert 和 delete)中的三个写操作 insert、update 和 delete 不太会出现性能问题(insert 一般不会有性能问题,update 和 delete 一般会有主键,所以也不会太慢)。除非索引建得太多,而数据库里的数据又太多,这三个操作才会变慢。

绝大多数情况下,select 是出现性能问题最大的地方。一方面,select 会有很多像 join、group、order、like 等这样丰富的语义,而这些语义是非常耗性能的;另一方面,大多数应用都是读多写少,所以加剧了慢查询的问题。

分布式系统中远程调用也会消耗很多资源,因为网络开销会导致整体的响应时间下降。为了挽救这样的性能开销,在业务允许的情况下,使用缓存是非常必要的事情。

缓存是提高性能最好的方式,一般来说,缓存有以下三种模式。

1. Cache Aside 更新模式

这是最常用的设计模式了,其具体逻辑如下。

  • 失效:应用程序先从 Cache 取数据,如果没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从 Cache 中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

这是标准的设计模式,为什么不是写完数据库后更新缓存?主要是怕两个并发的写操作导致脏数据。

那么,是不是这个 Cache Aside 就不会有并发问题了?不是的。比如,一个是读操作,但是没有命中缓存,就会到数据库中取数据。而此时来了一个写操作,写完数据库后,让缓存失效,然后之前的那个读操作再把老的数据放进去,所以会造成脏数据。

这个案例理论上会出现,但实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且有一个并发的写操作。实际上数据库的写操作会比读操作慢得多,而且还要锁表,读操作必须在写操作前进入数据库操作,又要晚于写操作更新缓存,所有这些条件都具备的概率并不大。

当然,最好还是为缓存设置好过期时间。

2. Read/Write Through 更新模式

在 Cache Aside 套路中,应用代码需要维护两个数据存储,一个是缓存,一个是数据库。所以,应用程序比较啰嗦。而 Read/Write Through 套路是把更新数据库的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。

可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的 Cache。

  • Read Through套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或 LRU 换出),Cache Aside 是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载,从而对应用方是透明的。
  • Write Through套路和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由 Cache 自己更新数据库(这是一个同步操作)。

3. Write Behind Caching 更新模式

Write Behind 又叫 Write Back。

Write Back 套路是在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的 I/O 操作飞快无比(因为直接操作内存嘛)。因为异步,Write Back 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但其带来的问题是,数据不是强一致性的,而且可能会丢失。在软件设计上,基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间、空间换时间一个道理。有时候,强一致性和高性能,高可用和高性能是有冲突的。软件设计从来都是 trade-off(取舍)。

4. 缓存设计的重点

  • 在分布式架构下,一般都需要一个外部的缓存集群。关于这个缓存集群,你需要保证的是内存要足够大,网络带宽也要好,因为缓存本质上是个内存和 IO 密集型的应用。
  • 如果需要内存很大,那么你还要动用数据分片技术来把不同的缓存分布到不同的机器上。这样,可以保证我们的缓存集群可以不断地 scale 下去。
  • 缓存的好坏要看命中率。缓存的命中率高说明缓存有效,一般来说命中率到 80% 以上就算很高了。当然,有的网络为了追求更高的性能,要做到 95% 以上,甚至可能会把数据库里的数据几乎全部装进缓存中。这当然是不必要的,也是没有效率的,因为通常来说,热点数据只会是少数。
  • 缓存是通过牺牲强一致性来提高性能的,这世上任何事情都不是免费的,所以并不是所有的业务都适合用缓存,这需要在设计的时候仔细调研好需求。
  • 缓存数据的时间周期也需要好好设计,太长太短都不好,过期期限不宜太短,因为可能导致应用程序不断从数据存储检索数据并将其添加到缓存。同样,过期期限不宜太长,因为这会导致一些没人访问的数据还在内存中不过期,而浪费内存。
  • 使用缓存的时候,一般会使用 LRU 策略。也就是说,当内存不够需要有数据被清出内存时,会找最不活跃的数据清除。所谓最不活跃的意思是最长时间没有被访问过了。所以,开启 LRU 策略会让缓存在每个数据访问的时候把其调到前面,而要淘汰数据时,就从最后面开始淘汰。
  • 对于 LRU 的缓存系统来说,其需要在 key-value 这样的非顺序的数据结构中维护一个顺序的数据结构,并在读缓存时,需要改变被访问数据在顺序结构中的排位。于是,我们的 LRU 在读写时都需要加锁(除非是单线程无并发),因此 LRU 可能会导致更慢的缓存存取的时间。这点要小心。
  • 我们的世界是比较复杂的,很多网站都会被爬虫爬,要小心这些爬虫。因为这些爬虫可能会爬到一些很古老的数据,而程序会把这些数据加入到缓存中去,而导致缓存中那些真实的热点数据被挤出去(因为机器的速度足够快)。对此,一般来说,我们需要有一个爬虫保护机制,或是我们引导这些人去使用我们提供的外部 API。在那边,我们可以有针对性地做多租户的缓存系统(也就是说,把用户和第三方开发者的缓存系统分离开来)。

二、异步处理

1. 异步处理的设计

在弹力设计中讲过异步通讯,这里,我们讲的是异步任务处理。当然,这没有什么冲突的,只不过是,异步通讯讲的是怎么把系统连接起来,而这里想讲的是怎么处理任务。

首先,我们需要一个前台系统,把用户发来的请求一一记录下来,有点像请求日志。这样,我们的操作在数据库或是存储上只会有追加的操作,性能会很高。我们收到请求后,给客户端返回“收到请求,正在处理中”。

然后,有个任务处理系统来真正地处理收到的这些请求。为了解耦,需要一个任务派发器,这里就会出来两个事,一个是推模型 Push,一个是拉模型 Pull。

  • 所谓 Push 推模型,就是把任务派发给相应的人去处理,有点像是一个工头的调度者的角色。
  • 而 Pull 拉模型,则是由处理的人来拉取任务处理。

这两种模型各有各的好坏。一般来说,Push 模型可以做调度,但是它需要知道下游工作结点的情况。除了要知道哪些是活着的,还要知道它们的忙闲程度。

而 Pull 的好处则是可以让上游结点不用关心下游结点的状态,只要自己忙得过来,就会来拿任务处理,这样可以减少一定的复杂度,但是少了整体任务调度。

一般来说,我们构建的都是推拉结合的系统,Push 端会做一定的任务调度,比如它可以像物流那样把相同商品的订单都合并起来,打成一个包,交给下游系统让其一次处理掉;也可以把同一个用户的订单中的不同商品给拆成多个订单。然后 Pull 端来订阅 Push 端发出来的异步消息,处理相应的任务。

2. 事件溯源

所谓 Event Sourcing,其主要想解决的问题是,我们可以看到数据库中的一个数据的值(状态),但我们完全不知道这个值是怎么得出来的。就像银行的存折一样,我们可以在银行的存折看到我们收支的所有记录,也能看得到每一笔记录后的余额。

Event Sourcing的好处:

  • 如果我们有了所有的收支流水账的记录,我们完全不需要保存余额,因为我们只需要回放一下所有的收支事件,就可以得到最终的数据状态。这样一来,我们的系统就会变得非常简单,只需要追加不可修改的数据操作事件,而不是保存最终状态。除了可以提高性能和响应时间之外,还可以提供事务数据一致性,并保留了可以启用补偿操作的完整记录和历史记录。
  • 如果我们的代码里有了 bug,在记录状态的系统里,我们修改 bug 后还需要做数据修正。然而,在 Event Sourcing 的系统里,我们只需要把所有事件重新播放一遍就好了,因为整个系统没有状态了。
  • 事件不可变,并且可使用只追加操作进行存储。 用户界面、工作流或启动事件的进程可继续,处理事件的任务可在后台异步运行。 此外,处理事务期间不存在争用,这两点可极大提高应用程序的性能和可伸缩性。
  • 事件是描述已发生操作的简单对象以及描述事件代表的操作所需的相关数据。 事件不会直接更新数据存储,只会对事件进行记录,以便在合适的时间进行处理。 这可简化实施和管理。
  • 事件溯源不需要直接更新数据存储中的对象,因而有助于防止并发更新造成冲突。
  • 最重要的是,异步处理 + 事件溯源的方式,可以很好地让整个系统进行任务的统筹安排、批量处理,可以让整体处理过程达到性能和资源的最大化利用。

关于 Event Sourcing 一般会和 CQRS 设计在一起。

3. 异步处理的分布式事务

  • CAP最终一致性解决方案
  • 两阶段提交强一致性解决方案
  • Saga模式处理微服务架构中长时间运行的事务方案

4. 异步处理的设计要点

异步处理中的事件驱动和事件溯源是两个比较关键的技术。

  • 异步处理可能会因为一些故障导致我们的一些任务没有被处理,比如消息丢失,没有通知到,或通知到了,没有处理。有这一系列的问题,异步通知的方式需要任务处理方处理完成后,给任务发起方回传状态,这样确保不会有漏掉的。
  • 发起方也需要有个定时任务,把一些超时没有回传状态的任务再重新做一遍,可以认为这是异步系统中的 " 对账 " 功能。当然,如果要重做的话,需要处理方支持幂等性处理。
  • 异步处理在处理任务的时候,并不知道能否处理成功,于是就会一步一步地处理,如果到最后一步不能成功,那么就需要回滚。这个时候需要有补偿事务的流程。
  • 并不是所有的业务都可以用异步的方式,比如一些需要强一致性的业务,使用异步的方式可能就不适合,这里需要我们小心地分析业务。
  • 在运维时,要监控任务队列里的任务积压情况。如果有任务积压了,要能做到快速地扩容。如果不能扩容,而且任务积压太多,可能会导致整个系统挂掉,那么就要开始对前端流量进行限流。

最后,异步处理系统的本质是把被动的任务处理变成主动的任务处理,其本质是在对任务进行调度和统筹管理。

三、数据库扩展

1. 读写分离

读写分离是数据库扩展最简单实用的玩法了,这种方法针对读多写少的业务场景还是很管用的,而且还可以有效地把业务做相应的隔离。

这样的方法好处是:

  • 比较容易实现。数据库的 master-slave 的配置和服务框架里的读写分离都比较成熟,
  • 应用起来也很快。可以很好地把各个业务隔离开来。不会因为一个业务把数据库拖死而导致所有的业务都死掉。
  • 可以很好地分担数据库的读负载,毕竟读操作是最耗数据库 CPU 的操作。

这样的方法不好的地方是:

  • 写库有单点故障问题。如果是写库出了性能问题,那么所有的业务一样不可用。对于交易型的业务,要得到高的写操作速度,这样的方式不行。
  • 数据库同步不实时,需要强一致性的读写操作还是需要落在写库上。

综上所述,一般来说,这样的玩法主要是为了减少读操作的压力。

这样的读写分离看上去有点差强人意,那么,我们还是为之找一个更靠谱的设计——CQRS。

2. CQRS

CQRS 全称 Command and Query Responsibility Segregation,也就是命令与查询职责分离。其原理是,用户对于一个应用的操作可以分成两种,一种是 Command 也就是我们的写操作(增,删,改),另一种是 Query 操作(查),也就是读操作。

Query 操作基本上是在做数据整合显现,而 Command 操作这边会有更重的业务逻辑。分离开这两种操作可以在语义上做好区分。

  • 命令 Command 不会返回结果数据,只会返回执行状态,但会改变数据。
  • 查询 Query 会返回结果数据,但是不会改变数据,对系统没有副作用。

这样一来,可以带来一些好处。

  • 分工明确,可以负责不同的部分。
  • 将业务上的命令和查询的职责分离,能够提高系统的性能、可扩展性和安全性。并且在系统的演化中能够保持高度的灵活性,能够防止出现 CRUD 模式中,对查询或者修改中的某一方进行改动,导致另一方出现问题的情况。
  • 逻辑清晰,能够看到系统中的哪些行为或者操作导致了系统的状态变化。
  • 可以从数据驱动(Data-Driven)转到任务驱动(Task-Driven)以及事件驱动。

如果把 Command 操作变成 Event Sourcing,那么只需要记录不可修改的事件,并通过回溯事件得到数据的状态。于是,我们可以把写操作给完全简化掉,也变成无状态的,这样可以大幅度降低整个系统的副作用,并可以得到更大的并发和性能。

这就是Event Sourcing 和 CQRS 的架构。

3. 分库分表 Sharding

一般来说,影响数据库最大的性能问题有两个,一个是对数据库的操作,一个是数据库中数据的大小。

对于前者,我们需要从业务上来优化。一方面,简化业务,不要在数据库上做太多的关联查询,而对于一些更为复杂的用于做报表或是搜索的数据库操作,应该把其移到更适合的地方。

对于后者,如果数据库里的数据越来越多,那么也会影响数据操作。而且,对于分布式系统来说,后端服务都可以做成分布式的,数据库最好也是拆开成分布式的。于是,分库分表就成了必须用的手段。

分库分表后数据变分布在不同的分片里了,为了不让服务感知到数据库的变化,需要引入一个叫 " 数据访问层 " 的中间件,用来做数据路由。为了避免数据访问层的麻烦,分片策略一般如下:

  • 按多租户的方式。用租户 ID 来分,这样可以把租户隔离开来。
  • 按数据的种类来分。比如,一个电商平台的商品库可以按类目来分,或是商家按地域来分。
  • 通过范围来分。这样分片,可以保证在同一分片中的数据是连续的,于是数据库操作,比如分页查询会更高效一些。一般来说,大多数情况是用时间来分片的,比如,一个电商平台的订单中心是按月份来分表的,这样可以快速检索和统计一段连续的数据。
  • 通过哈希散列算法来分(比如:主键 id % 3 之类的算法。)此策略的目的是降低形成热点的可能性。但是,这会带来两个问题,一个就是跨库跨表的查询和事务问题,另一个就是如果要扩容需要重新哈希部分或全部数据。

上面是最常见的分片模式,但是还应考虑应用程序的业务要求及其数据使用模式。这里请注意几个非常关键的事宜:

  1. 数据库分片必须考虑业务,从业务的角度入手,而不是从技术的角度入手,如果你不清楚业务,那么无法做出好的分片策略。
  2. 请只考虑业务分片。请不要走哈希散列的分片方式,除非有个人拿着刀把你逼到墙角,你马上就有生命危险,你才能走哈希散列的分片方式。

4. 数据库扩展的设计重点

从业务层上把单体的数据库给拆解掉的相关重点:

  • 需要把数据库和应用服务一同拆开。也就是说,一个服务一个库,这就是微服务的玩法,这样一来,你的数据库就会被 " 天生地 " 给拆成服务化的,而不是一个单体的库。

    在一个单体的库上做读写分离或是做分片都是一件治标不治本的事,真正治本的方法就是要和服务一起拆解。

  • 当数据库也服务化后,我们才会在这个小的服务数据库上进行读写分离或分片的方式来获得更多的性能和吞吐量。这是整个设计模式的原则——先做服务化拆分,再做分片。

  • 对于分片来说,有两种分片模式,一种是水平分片,一种是垂直分片。

    • 水平分片就是上面内容说的那种分片。
    • 垂直分片是把一张表中的一些字段放到一张表中,另一些字段放到另一张表中。垂直分片主要是把一些经常修改的数据和不经常修改的数据给分离开来,这样在修改某个字段的数据时,不会导致其它字段的数据被锁而影响性能。

分库分表更多的是说水平分片。水平分片需要有以下一些注意事项:

  • 随着数据库中数据的变化,我们有可能需要定期重新平衡分片,以保证均匀分布并降低形成热点的可能性。但是,重新平衡是一项昂贵的操作。 若要减少重新平衡的频率,需要通过确保每个分片包含足够的可用空间来处理未来一段时间的变化。另外,还需要开发用于快速重新平衡分片的工具和脚本。
  • 分片是静态的,而数据的访问则是不可预期的,可能需要经常性地调整我们的分片,这样一来成本太高。所以,最好使用一个索引表的方式来进行分片。也就是说,把数据的索引动态地记录在一个索引表中。这样一来,就可以非常灵活地调度我们的数据了。当数据调度到另一台节点上时,只需要去索引表里改一下这个数据的位置就好了。
  • 如果程序必须要从多个分片检索数据的查询,则可以使用并行任务从各个分片上提取此数据,然后聚合到单个结果中。 但是,此方法不可避免地会在一定程度上增加解决方案数据访问逻辑的复杂性。
  • 数据分片后,很难在分片之间保持引用完整性和一致性,也就是所谓的跨分片的事务,因此应尽量减少会影响多个分片中的数据的操作。如果应用程序必须跨分片修改数据,那么需要评估一致性以及评估是否采用两阶段提交的方式。
  • 配置和管理大量分片是一个挑战。在做相应的变更时,一定要先从生产线上拉出数据,然后根据数据计划好新的分片方式,并做好相当的测试工作。否则,这个事出了问题会是一个灾难性的问题。

四、边缘计算

所谓边缘计算,它是相对于数据中心而言。数据中心喜欢把所有的服务放在一个机房里集中处理用户的数据和请求,集中式部署一方面便于管理和运维,另一方面也便于服务间的通讯有一个比较好的网络保障。的确没错。不过,我们依然需要像 CDN 这样的边缘式的内容发布网络,把我们的静态内容推到离用户最近的地方,然后获得更好的性能。

1. 为什么要有边缘计算

  • 从趋势上来说:

    • 从一开始,我们处在 MB 时代,那个时候,电脑也是几百兆的硬盘就够了。
    • 然后,开始进入 UGC 时代,用户开始产生数据,他们写博客,发贴子,拍照片……,信息越来越多,于是计算机的硬件,网络的基础设施都在升级。
    • 再然后,我们进入了大数据时代,这个时代也是移动互联网的时代。

    我们可以看到,数量越来越大,分析结果的速度需要越来越快,这两个需求,只会把我们逼到边缘计算上去。 如果你还是在数据中心处理,你会发现你的成本只会越来越高,到一定时候就完全玩不下去了。

  • 从成本上来说:

    • 几十万用户的公司,只需要处理百级 QPS 的量,只需要 10 台左右的服务器;
    • 上百万用户的公司,只需要处理千级 QPS 的量,需要有 50 台左右的服务器;
    • 上千万用户的公司,需要处理万级到十万级 QPS 的量,需要 700 台左右的服务器;
    • 上亿用户的公司,其需要处理百万级 QPS 的量,需要上万台的服务器。

    这是因为,当架构变复杂了后,就要做很多非功能的东西了,比如,缓存、队列、服务发现、网关、自动化运维、监控等。

    我们不妨开个脑洞。如果我们能够把那上亿的用户拆成 100 个百万级的用户,那么只需要 5000 多台机器(100 个 50 台服务器的数据中心)。我们还是同样服务了这么多的用户,但我们的成本下降得很快。

好了,问题来了,什么样的业务可以这么做?至少有地域性的业务是可以这么做的。比如:外卖、叫车、共享单车之类的。

我们完全可以用边缘结点处理高峰流量,这样,我们的数据中心就不需要花那么大的成本来建设了。

2. 边缘计算的业务场景

边缘计算一定会成为一个必然产物,其会作为以数据中心为主的云计算的一个非常好的补充。这个补充其主要是做下面一些事情:

  • 处理一些实时响应的业务。它和用户靠得很近,所以可以实时响应用户的一些本地请求,比如,某公司的人脸门禁系统、共享单车的开锁。
  • 处理一些简单的业务逻辑。比如像秒杀、抢红包这样的业务场景。
  • 收集并结构化数据。比如,把视频中的车牌信息抠出来,转成文字,传回数据中心。
  • 实时设备监控。主要是线下设备的数据采集和监控。
  • P2P 的一些去中心化的应用。比如:边缘结点作为一个服务发现的服务器,可以让本地设备之间进行 P2P 通讯。
  • 云资源调度。边缘结点非常适合用来做云端服务的调度。比如,允许用户使用不同生产商的云存储服务,使用不同生产商但是功能相同的 API 服务(比如支付 API 相关)。因为是流量接入方,所以可以调度流量。
  • 云资源聚合。比如,我们可以把语音转文字的 API 和语义识别的 API 相结合,聚合出来一个识别语音语义的 API,从而简化开发人员的开发成本。
  • ……其实还有很多,边缘计算带来的想象力还是很令人激动的。

3. 边缘计算的关键技术

  • API Gateway。关于网关,在管理设计篇中已经讲过了。
  • Serverless/FaaS。就是服务函数化,这个技术就像是服务一样,你写好一个函数,然后不用关心这个函数运行在哪里,直接发布就好了。然后就可以用了。

4. 使用场景:秒杀

“秒杀”的技术挑战:技术上的挑战就是怎么应对这 100 万人同时下单请求?100 万的同时并发会导致我们的网站瞬间就崩溃了,一方面是 100 万人同时请求,我们的网络带宽不够,另一方面是理论上来说要扛 100 万的 TPS,需要非常多的机器。最恐怖的是,所有的请求都会集中在同一条数据库记录上,无论是怎么分库分表,还是使用了分布式数据库都无济于事,因为面对的是单条的热点数据。

秒杀的解决方案:要让 100 万用户能够在同一时间打开一个页面,这个时候就需要用到 CDN 了。数据中心肯定是扛不住的。

  • 在 CDN 上,这 100 万个用户就会被几十个甚至上百个 CDN 的边缘结点给分担了,于是就能够扛得住。然后,我们还需要在这些 CDN 结点上做点小文章。
  • 一方面,需要把小服务部署到 CDN 结点上去,这样,当前端页面来问开没开始时,这个小服务除了告诉前端开没开始外,它还可以统计下有多少人在线。
  • 假设,我们知道有大约 100 万的人在线等着抢,那么,在快要开始的时候,由数据中心向各个部署在 CDN 结点上的小服务上传递一个概率值,比如说是 0.02%。
  • 于是,当秒杀开始的时候,这 100 万用户都在点下单按钮,首先他们请求到的是 CDN 上的这些服务,这些小服务按照 0.02% 的量把用户放到后面的数据中心,也就是 1 万个人放过去两个,剩下的 9998 个都直接返回秒杀已结束。
  • 于是,100 万用户被放过了 0.02% 的用户,也就是 200 个左右,而这 200 个人在数据中心抢那 100 个 iPhone,也就是 200 TPS,这个并发量怎么都应该能扛住了。

这就是整个“秒杀”的技术细节,是不是有点不敢相信?