提高网站伸缩性的实用技术

在大容量网站上工作 8 年之后,我见识了许多有趣的扩缩容技术。 虽然制定具体策略也能提高效率,但要实现高并发还是需要一定成本的。 按照我的经验,中等规模网站若遇到的扩缩问题可以通过下面方式解决:

  • 异步工作队列

  • 使用合理的数据库

  • 适当的缓存

下面的示例是在 Ruby on Rails 的上下文中编写的,但也适用于其他语言和框架。

异步工作队列

随着一个网站访问量的增大,控制器的工作量变得越来越多,额外的工作量会使得控制器变慢,例如:

  • 分析跟踪

  • 发送电子邮件

  • 创建附加数据库记录

  • N+1 查询副作用

假若有人想删除有许多记录的个人资料,这可能就需要一点时间来办到。但是,网站并不能立即删除,而是需要确认请求并且在删除后完成邮件发送。实际的删除动作可以以一种异步工作队列的方式执行。异步工作队列的由以下两部分组成:

  1. 可执行的工作和工作参数(通常在 Redis、MongoDB、MySQL 等)队列。

  2. 从队列中取出工作并执行的 worker 池。

基本上,任何事情都可以通过控制器来推迟:

  • 第三方的 API 请求肯定会有延迟,因为它们的响应时间是个变量,可能会变慢, 而 Web 请求在渲染时不能被响应阻塞。这时,如果不能合理地检测和终止缓慢的响应,失灵的 API 提供程序会导致网站访问失败。这时的解决办法是调用异步的 API。

  • Email,基本上也是一个 API 请求。

  • 创建数据库记录时,尽管请求的速度比 API 请求高一个数量级,但插入操作还是需要 10–100 毫秒,因为影响的因素有数据库负载,大量的索引和外键约束。

只有当结果影响到响应时,API 请求才不能延迟。例如:一个支付请求可能被阻塞,那么客户就会立刻被通知支付失败。

除了 Web 请求周期,异步工作队列对并行化大的工作负载是非常有用的。举例来说,想象一个日常要运行的计划脚本,宣布一个新产品发布,需要发送给 10k 个邮箱地址。如果该脚本要发送完清单中的所有邮件,可能需要超过一个小时。而十几个进程异步工作可能只要5分钟。如果你需要处理高峰时候的请求,可以简单地扩展规模。

异步工作对于处理各种工作都是很有效的,并且实现的花费比较低,是在各种情况下务实的解决方案。

正确地使用数据库

或许你的网站使用的编程语言非常通用化,它非常易于开发网站。而一个数据库则是基于关系代数的高度优化计算引擎。市面上已有许多数据库相关的技术书籍,在此我仅提及一些常见场景。

N+1 查询问题

对于对象关系映射(ORMS),尤其是 Rails 的 ActiveRecord 抱怨最多的是:它们很容易在程序员不知情的情况下产生性能很差的查询。考虑下面的案例:

User.each { |u| puts u.address }

这段代码看起来一切都好。但是 Rails 正在执行一个查询来加载 User 关系,然后循环的每次迭代中查询 Address 表:

SELECT * FROM users;
SELECT * FROM addresses WHERE user_id = 1;
SELECT * FROM addresses WHERE user_id = 2;
...

这种查询性能问题被称为 N+1 查询问题[译者注:执行了 N+1 步查询]。并且很容易通过分析 SQL 日志来识别。修补办法也很直接:取代在一个循环里查询的方式,我们在一条语句里收集所有的用户 ID,然后查询:

SELECT * FROM addresses WHERE user_id in (SELECT id FROM users);
SELECT * FROM users INNER JOIN addresses ON (users.id = addresses.user_id);

Ruby on Rails 提供了几个 “贪婪加载” 的机制以便于我们规避掉 N+1 查询模式:

User.includes(:address).each {|u| puts u.address}

该指令在其底层会指示 Rails 使用 SELECT..FROM..WHERE id IN (?) 模式根据 user_id 从 user 表 select 到相应的数据位置。

缺少索引

在通过任意列查询一个表时,数据库引擎有两个选择: 遍历表中的所有记录,或者利用索引 —?这是一种被高度优化过的数据结构,可以被用来在进行对表的便利操作之前,先对记录进行筛选或者剔除。

在小于1万条记录的表上,因为查询起来已经够快了,所以你不会意识到索引的缺位。而超过1万条的话,查询操作会明显变缓慢。外键关联关系 (other_table_id) 是可以放上一个索引的明显位置。全覆盖型索引可以让需要返回的信息都存在于索引之中,这样就能在不访问数据表本身的情况下对查询加速。如果你不确定自己是否使用到了索引,可以使用数据库引擎的 EXPLAIN 命令来一探究竟:

EXPLAIN SELECT * FROM foo;

                       QUERY PLAN
---------------------------------------------------------
 Seq Scan on foo  (cost=0.00..155.00 rows=10000 width=4)
(1 row)

EXPLAIN SELECT sum(i) FROM foo WHERE i < 10;

                             QUERY PLAN
--------------------------------------------------------------------
 Aggregate  (cost=23.93..23.93 rows=1 width=4)
   ->  Index Scan using fi on foo  (cost=0.00..23.92 rows=6 width=4)
         Index Cond: (i < 10)
(3 rows)

该工具会迅速地向你显示出数据库引擎将会使用的策略。即使你正在使用的是另外一种数据库,也可以参考 Postgre 的 EXPLAIN 文档。优秀的数据库管理员工具PgHero也会基于使用统计数据自动的展示一些添加/删除索引的建议。

序列化数据

关系代数用于结构化数据: 预定义的表、列以及关系。而像 XML 和 JSON 这些半结构化数据并非总是已经预定义好的。传统的解决办法就是将你的 XML 和 JSON 序列化成为一条文本记录。而新近的数据库版本已经加入了对 JSON(原生的文本存储)以及 JSONB(二进制结构)数据列的本地支持。

API 响应消息就是可以存储到一个数据库中去的流行的 JSON 结构。使用一种原生的 JSON 格式,API 响应的详细信息就能够很容易的在数据库中进行查询,而不用等到编程语言进行反序列化之后:

SELECT * FROM stripe_charges WHERE (transfer->'amount')::int > 1000;

你也可以从一个大型的 JSON 对象中提取一个单独的值:

SELECT (transfer->'created'), (transfer->'amount')::int
FROM stripe_charges;

在数据库中进行聚合操作

想象一下,如果你要挑选出所有那些已经消费了超过 $100 的用户数据:

users = User.includes(:payments).select do |u|
  u.payments.map(&:amount).sum > 100
end

这样做会为每一条存在于表中的用户数据实例化一个 ActiveRecord 对象,还有表中的每一条支付记录, 然后在不需要之后就它们扔在一边。换种方式,其实我们可以告诉数据库先做一下过滤,然后只返回那些符合查询条件的用户记录:

User
  .joins(:payments)
  .group('users.id')
  .select("users.*, SUM(payments.amount) AS total_amount")
  .having("SUM(payments.amount) > 100")

这样做以后就会生成合适的 JOIN, HAVING, 以及 GROUP BY 操作:

SELECT users.id, users.name, SUM(payments.amount) AS total_amount 
FROM users
INNER JOIN payments ON payments.user_id = users.id
GROUP BY users.id
HAVING SUM(payments.amount) > 100

数据库封装

数据库是成熟的、高度优化过的数据存储。无论什么时候都要尽可能让数据库做它们擅长的而不是只会在你的 web 编程语言中拖慢速度的事情。


适当的缓存

在开发社区中的一个常识是,计算机科学中只有两个难点:命名,缓存和缓冲溢出错误。缓存可能很难做到准确,这也导致难以追踪的旧值错误。我不是绝对地断言说缓存是一个银弹;但它在你的工具集中肯定是一个领先的利器。
通常,整个响应都可以被缓存,诸如:

  • 对每个用户均是同样响应的API终端(例如产品目录,搜索typehead数据库)

  • 退出后的首页

  • sitemap.xml

这些可缓存的响应都是些我最喜欢的简单的性能改进方法。在缓存命中的情况下,不需要调用任何模板引擎,web服务器就能立即开始向客户端提供内容。只需要几乎不可能达到的0.1ms响应时间。如果50%的流量都通过注销的首页进入,缓存整个页面便具有巨大的优势,特别是Google将网页响应时间列为搜寻结果排序的度量之后。除了有利于用户和排名外,缓存的响应也将减轻服务器负载。

如果最初不是所有响应都可缓存,那再找找原因。 登录的主页可能只是一个带有登录菜单栏的注销页面; 在这种情况下,您可以快速响应已注销的主页,然后使用 AJAX 将已登录的菜单替换已注销的菜单。


对 www.cameralends.com 的请求中超过 50% 的是在 10ms 内提供的。
即使整个响应不可缓存,耗费高昂的数据也可以缓存:

  • 耗时的查询结果(例如 像SELECT COUNT(*)、复杂的连接这些聚合查询)
  • 需要大量计算的、在同一个页面上多次使用的或可长时间缓存的视图局部
  • 第三方 API 响应
缓存的一个常见问题是缓存蜂拥(cache stampede)问题:
Cache stampede
A cache stampede is a type of cascading failure that can occur when massively parallel computing systems with caching(缓存蜂拥是一种级联故障,可能发生在大规模并行计算系统的缓存中)——en.wikipedia.org

该问题涉及到缓存过期的现象; 想象下如果有一个要花掉10秒钟来生成响应消息的JSON端点:

  1. 在过期之前,它都会在 0.1 毫秒之内返回;

  2. 它过期了,并且被逐出了缓存空间;

  3. 第一个尝试去访问该缓存值的进程发现它已经不见了,接着就开始执行前面提到的那个要花费 10 秒钟时间的计算过程;

  4. 0.1 秒之后,第二个进程也尝试着去访问该缓存值,同样发现其不见了,并且又启动了要花费 10 秒钟时间的计算过程。

如果请求每过 0.1 秒钟就进来一次, 那么在最开始执行的耗时10秒钟的请求结束之前,就已经有 100 个代价昂贵的请求开始在处理了。如果它还能结束的话!如果它是一个昂贵的数据库查询操作,负载就会大幅地增加,也就可能会让首次查询操作花掉 20秒钟 (或者更多的时间!) 才能结束, 而那样的话就足够引发停机故障了。如果只有 100 个 web worker 的话,那么它就会导致停机故障。

一个通用的解决方案就是使用一个计划作业来在过期之前对值进行一次缓存操作 (或者甚至可能让该值永不过期,那样就总是会有快速的响应)。这一策略添加起来会有点呆板,所以会有点消磨人们使用它的念头。这就是为什么我更喜欢使用一种异步缓存重计算策略的原因了。

像产品目录这样的数据在一天或者一周之内可能只会变化几次。如果是这样的话,让成千上万次请求接收到的响应数据稍显陈旧一点,其实是可以接受的。我所使用的异步缓存其工作逻辑如下:

  1. 在过期之前,它都会在0.1毫秒之内返回;

  2. 它会经历柔性的过期过程: 不会被逐出缓存而只是被标记为旧数据;

  3. 柔性过期会对异步的刷新缓存值的作业进行排队;

  4. 那些在异步作业结束之前访问到旧数据值的进程将会在0.1毫秒的时间接收到旧的缓存值;

  5. 在异步作业结束之后,缓存值会被更新而旧数据的标记会被清理掉。

该策略能让你总是可以快速的返回数据,除非是初次访问而要获取的值需要进行计算。而你也可以让它返回nil,并且进行常规的降级操作, 同时对异步的作业进行排队,为以后的响应操作缓存好数据值。

Rails 提供了一种简单的方案借助 ActiveSupport::Cache::Store#fetch 中的 race_condition_ttl 来进行缓存缓冲操作。当你设置好了该参数,首个访问到旧数据值的进程就会:

  1. 通过 race_condition_ttl 来延长缓存的到期时间;

  2. 对缓存值进行重新计算。

第二次以及后续访问该缓存键的进程会返回稍显陈旧的数据值。如果重新计算出来的值还没有被 race_condition_ttl 所缓存, 当前的进程就会触发进程并进行重新计算。值得注意的是有些不那么走运的请求会被重新计算的任务阻延,因此该策略对于用户体验会有所影响。

静态缓存

静态缓存对于大量固定内容来说是个不错的选择。 静态网站生成器(如Jekyll)可用于构建整个文件结构,并通过 Nginx 或 Apache 提供服务。在 Heroku 上,您可以简单地使用 Rack来提供静态目录。

结语

这并不是一个全面的优化指南,但可以让你的网站具有更好的可扩展性。 在添加一个复杂的性能解决方案之前,先看看上是否有更直接的可行的解决方案。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值