作为最受欢迎的 IT 技术网站之一,Stack Overflow 如何做应用缓存?

缓存是什么?这是一种无需重复计算或者反复获取,即可快速得到反馈的方法,用于提升性能水平并优化资源成本。下面咱们马上进入正题,聊聊缓存的实现方式。

我们假设这里需要调用某个 API、查询某数据库服务器或者只是选取几个高达数百万位的数字并进行相加。这些都需要占用大量资源,所以最好是把结果缓存起来,以备未来快速重复使用。

为什么要进行缓存?

在这里,我认为有必要聊聊之前提到的这些任务到底需要多少资源成本。现代计算机中存在多个缓存层。举例来说,我们建立一个 Web 服务器,其中配备英特尔至强 E5-2960 v3 CPU 以及 2133 MHz DIMM。缓存访问的实质,就是计算需要占用处理器的“多少个周期”;因此在使用这块主频为 3.06 GHz(性能模式)的处理器时,可以推导出相关延迟(这里使用的英特尔处理器皆为 Haswell 架构):

  • 一级缓存(每核心):4 个周期,约 1.3 纳秒——12x 32 KB + 32 KB
  • 二级缓存(每核心):12 个周期,约 3.92 纳秒延迟——12x 256 KB
  • 三级缓存(共享):34 个周期,约 11.11 纳秒延迟——30 MB
  • 系统内存:约 100 纳秒延迟——8x 8 GB

各个缓存层的存储容量越大,距离也就越远。这是处理器设计当中做出的取舍,旨在实现最佳平衡。举例来说,各个核心的内存量越大,则其一般来讲与核心芯片间的距离越远,意味着延迟、机会成本以及功耗成本都将随之上升。在这方面,电荷的实际运行距离会造成重大的影响;每一秒,该距离都相当于增加了数十亿倍。

另外,这里我没有提到磁盘延迟,因为我们很少使用磁盘。为什么?这个嘛,我可能需要解释一下……在 Stack Overflow,除了备份或者日志服务器的其它一切生产负载都在 SSD 上实现。我们的本地存储通常分为以下几层:

  • NVMe SSD: 约 120 微秒
  • SATA 或 SAS SSD: 约 400 至 600 微秒
  • 机械磁盘:2 至 6 毫秒

具体性能一直在变化,所以大家不用关注这些数字。我们需要了解的是这些存储层之间的差异量级。下面,让我们整理出一份更明确的比较清单(全部使用最佳性能数字):

  • L1: 1.3 纳秒
  • L2: 3.92 纳秒 (延迟为 3 倍)
  • L3: 11.11 纳秒 (延迟为 8.5 倍)
  • DDR4 RAM: 100 纳秒 (延迟为 77 倍)
  • NVMe SSD: 12 万纳秒 (延迟为 92307 倍)
  • SATA/SAS SSD: 40 万纳秒 (延迟为 30 万 7692 倍)
  • 机械磁盘: 2–6 毫秒 (延迟为 153 万 8461 倍)
  • Microsoft Live 登录: 12 次转发,5 秒 (延迟约为 38 亿 4615 万 3846 倍)

如果大家对这些数字没啥概念,那么下图使用滑块整理出了可视化版本的比较结果(可以看到各缓存层的变化趋势):

由于性能数字和量级差距过大,让我们再添加一些日常环境中经常出现的重要指标。假设我们的数据源为 X——这个 X 究竟是什么无所谓,可以是 SQL、微服务、宏服务、leftpad 服务、Redis 或者磁盘文件等,最重要的是需要将源性能与内存性能进行比较。下面来看源性能:

  • 100 纳秒(来自内存——快!)
  • 1 毫秒(延迟为 1 万倍)
  • 100 毫秒(延迟为 100 万倍)
  • 1 秒(延迟为 1 亿倍)

看到这里,相信大家已经能够理解,即使延迟仅为 1 毫秒,其速度也要远低于内存的速度。这里的单位分别为毫秒、微秒与纳秒,三者皆为 1000 进位——1000 纳秒 =1 微秒。

但是,并不是所有缓存都属于本地缓存。例如,我们会在自己的 Web 层之后利用 Redis 建立共享缓存(后文将具体介绍)。假设我们正在打算通过内部网络进行访问,整个往返需要 0.17 毫秒,此外我们还要发送一些数据。对于小型数据包,延迟大约在 0.2 毫秒到 0.5 毫秒之间。虽然这样的延迟仍是本地内存的 2000 至 5000 倍,但仍比大多数数据源快得多。请注意,这些数字看起来比较小,是因为我们设定的是一套小型本地局域网。云端延迟通常会更高,实际延迟水平需要自行测量。

在获取数据的同时,我们可能还打算以某种方式进行处理。也许我们需要进行相加、过滤、对其进行编码等。总而言之,我们希望只对〈x〉进行一次处理,后续直接提取处理结果。

有时候,我们希望降低延迟;有时候则希望节约 CPU 资源。这两项因素基本上构成了引入缓存机制的全部理由。接下来,我们会从反方向进行论证。

为什么不使用缓存?

我知道很多朋友最讨厌缓存,这部分一定特别符合您的心意。是的,我这人就是典型的墙头草。

前面说了这么多好处,为什么有时候不适合用缓存呢?这是因为任何一项决策都需要进行利弊权衡。是的,任何一项决策。即使是面对简单如时间成本或者机会成本之类的问题,权衡也仍有必要。

在缓存方面,添加缓存可能带来以下成本:

  • 在必要时清除缓存值(缓存失效——我们将在后文中具体说明)
  • 缓存占用内存
  • 访问缓存造成的延迟(与直接访问数据源相比)
  • 系统结构更为复杂,因此调试工作将面临更高的时间与精力成本

每当有新功能可能需要配合缓存时,我们都需要进行评估……而且评估工作难度很大。

在 Stack Overflow,我们的架构遵循一项总体性的原则:尽可能简单。所以简单,就是易于评估、推理、调度以及变更。只有在必须复杂时,才允许其复杂。缓存当然也要遵循这一原则。因为缓存会带来更多工作量与精力投入,所以除非必要,否则不用。

所以,我们得首先回答以下几个问题:

  • 使用缓存能够显著提高速度吗?
  • 我们能够节约哪些资源?
  • 这些数据值得存储吗?
  • 存储的数据是否值得清理(例如使用垃圾收集机制)?
  • 这是否会立刻带来大型对象堆?
  • 我们需要多久进行一次清理?
  • 每个缓存条目会有多少次命中?
  • 缓存失效是否会影响到其它功能?
  • 未来会出现多少变种?
  • 我们的缓存分配是否被用于计算密钥?
  • 采用本地还是远程缓存?
  • 不同用户之间是否共享同一缓存?
  • 不同站点之间是否共享同一缓存?
  • 管理以及调试过程是否非常困难?
  • 缓存属于什么级别?

这些都是缓存决策当中必须搞清楚的问题。本文也将尽可能将其纳入讨论。

Stack Overflow 的缓存分层

在 Stack Overflow,我们也有自己的“一级 / 二级”缓存。但为了避免与真正的名词相混淆,这里我会强调更准确的术语表达。

  • “全局缓存”:内存内缓存(全局、每 Web 服务器、未命中时由 Redis 支持)
    • 其对应用户顶栏,在整个网络中共享使用。
    • 命中本地内存(共享键空间),而后是 Redis(共享键空间,使用 Redis database 0)。
  • “站点缓存”:内存内缓存(每站点、每 Web 服务器,未命中时由 Redis 支持)
    • 通常为各站点的问题列表或者用户列表。
    • 命中本地内存(第站点键空间,使用前缀),而后使用 Redis(每站点键空间,使用 Redis 数据库)
  • “本地缓存”:内存内缓存(每站点、每 Web 服务器、无后备任何支持)
    • 通常为易于获取的内容,且规模过大但又不足以动用 Redis 堆。
    • 仅命中本地内在(每站点键空间,使用前缀)。

这里说的“每站点”是什么意思?Stack Overflow 与 Stack Exchange 站点网络是一套多租户架构。Stack Overflow 只是数百个站点中的一个。这意味着 Web 服务器上的单一进程托管着全部站点,因此我们只在必要时才会进行缓存拆分。另外,我们必须对缓存进行清理(后文将具体介绍如何清理)。

Redis 之前,我们讨论过了服务器与共享缓存的工作原理。接下来让我们再快速了解一下共享内容的实现基础:Redis。那么,Redis 是什么?这是一套开源键 / 值数据存储方案,其中包含多种实用数据结构,大量发布 / 订阅机制以及坚如磐石的稳定性水平。

为什么要选择 Redis,而非其它解决方案?答案很简单,因为 Redis 就够了。它表现得很好,能够充分满足我们对于共享缓存的需求。另外,它的稳定性令人稀奇,速度表现也无可挑剔。我们很清楚要如何使用 Redis、如何进行监控、如何加以维护。而且在必要时,我们也能对 Redis 库做出调整。

Redis 成为我们整体基础设施中最不用操心的组成部分。我们基本上已经将其视为一种理所当然的解决方案(当然,我们也设置有高可用性副本)。在选择基础设施时,我们不仅需要根据潜在价值进行调整,同时也要考虑调整可能带来的成本、时间投入以及风险。如果现在的解决方案就能很好地完成任务,我们为什么要投入大量时间与精力,甚至承担由此引发的风险?当然没必要。时间总是宝贵的,拿来做点有意义的事情不是更好?例如——讨论哪种缓存服务器更强!

下面,我们通过几个 Redis 实例对应用问题进行具体剖析(但这些应用都处于同一组服务器上)。先从下图开始:

我们首先把从上周二(2019 年 7 月 30 日)以来的一些快速统计数据整理出来。这些数据涵盖了各大主要设备上的全部实例(这里按组织方式进行分类,而非按性能分类……单一实例可能就足以承载我们的全部日常负载):

  • 我们的 Redis 物理服务器拥有 256 GB 内存,但实际使用量只有不足 96 GB。
  • 每天共需处理 15 亿 8655 万 3473 条命令(由于需要复制备份,实际命令量为 37 亿 2658 万 897 条,峰值期间每秒 8 万 6982 条命令)。
  • 整体服务器的 CPU 平均利用率为 2.01%(峰值期间为 3.04%)。其中最活跃的实例,资源占比也不足 1%。
  • 共计 1 亿 2441 万 5398 个活动键(计入复制操作,则为 4 亿 2281 万 8481 个)。
  • 这些数字贯穿 3 亿 806 万 5226 次 HTTP 命中(其中 6471 万 7337 次命中问题页面)。

备注:Redis 并未限制性能水平,我们也没有设定任何限制。这里只列出实例当中的实际活动情况。

除了缓存需求之外,我们选择 Redis 还有其它一些理由:我们还使用其中的发布 / 订阅机制实现 websockets 的实时计分及复制等功能。Redis 5.0 还添加了 Streams,它非常适合我们的 websocket。所以在其它基础设施组件更新到位之后(主要受到 Stack Overflow 企业版的限制),我们可能会进行一波全面迁移。

内存内与 Redis 缓存

之前提到的所有实例,都包含一个内存内缓存组件,有一些还由 Redis 服务器负责提供后备。

内存内非常简单,我们就是把数据缓存在……内存里面。 以ASP.NET MVC 5 为例,以往的作法是 HttpRuntime.Cache 。如今, 我们已经做好ASP.NET Core 的迁移准备,所以现在的方法变成了  MemoryCache 。二者之间的区别不大,也不是很重要;它们都提供了一种能够在特定时间段内缓存对象的方法。这就足够了,我们需要的就是这个。

对于以上几种缓存方式,我们会选择一个“数据库 ID”,具体与我们在 Stack Exchange Network 上的站点相关,因此由我们的 Sites 数据库表决定。Stack Overflow 为 1,Server Fault 为 2,Super User 为 3 等等。

对于本地缓存,大家可以通过多种方式实现。我们的方法非常简单:ID 为缓存态的一部分。对于全局缓存(共享),ID 为 0。我们进一步(出于安全)为每个缓存添加前缀,以避免这些应用级缓存与可能存在的任意其它键名称发生冲突。下面来看一个示例键:

复制代码

prod:1-related-questions:1234

这是 Stack Overflow(ID 为 1)上关于问题 1234 的侧栏相关问题。如果只使用内存内缓存,那么序列化将不再重要,因为我们可以直接缓存任何对象。但是,如果我们将某个缓存对象发送至某处(或者由某处发回),则必须对其快速进行序列化。正因为如此,我们才编写出自己的 protobuf-net。Protobuf 是一种二进制编码格式,在速度与分配方面都非常高效。我们希望缓存的简单对象可能如下所示:

复制代码

publicclassRelatedQuestionsCache

{

publicintCount {get;set; }

publicstringHtml {get;set; }

}

在利用 protobuf 属性控制序列化之后,则如下所示:

复制代码

[ProtoContract]

publicclassRelatedQuestionsCache

{

[ProtoMember(1)]publicintCount {get;set; }

[ProtoMember(2)]publicstringHtml {get;set; }

}

现在,假设我们希望将该对象缓存在站点缓存当中。那么整个流程将如下所示(代码稍微简化了一点):

复制代码

publicT Get<T>(stringkey)

{

// Transform to the shared cache key format, e.g. "x" into "prod:1-x"

varcacheKey = GetCacheKey(key);

// Do we have it in memory?

varlocal= memoryCache.Get<RedisWrapper>(cacheKey);

if(local!=null)

{

// We've got it local - nothing more to do.

returnlocal.GetValue<T>();

}

// Is Redis connected and readable? This makes Redis a fallback and not critical

if(redisCache.CanRead(cacheKey))// Key is passed here for the case of Redis Cluster

{

varremote = redisCache.StringGetWithExpiry(cacheKey)

if(remote.Value !=null)

{

// Get our shared construct for caching

varwrapper = RedisWrapper.From(remote);

// Set it in our local memory cache so the next hit gets it faster

memoryCache.Set<RedisWrapper>(key, wrapper, remote.Expiry);

// Return the value we found in Redis

returnremote.Value;

}

}

// No value found, sad panda

returnnull;

}

当然,这里的代码经过了大大简化,但我们不会遗漏任何重要的内容。

为什么要使用 RedisWrapper ?因为类似于 Redis,它通过将值与 TTL(生存时间)搭配起来实现了平台概念同步。它还允许我们对 null 值进行缓存,且无需使用特殊的处理方式。换句话说,这样我们就能分辨“内容没有经过缓存”和“经过缓存的内容为 null”之间的区别。如果大家对 StringGetWithExpiry 抱有兴趣,我再多提几句。它属于 StackExchange.Redis 方法,负责将多项命令整合起来以通过一次调用同时返回值与 TTL(不再消耗双倍时间)。

使用 Set  对值进行缓存的方式完全相同 :

  1. 将该值缓存在内存当中。
  2. 将该值缓存在内存 Redis 当中。
  3. (可选)提醒其它 Web 服务器该值已经更新,并指示各服务器刷新副本。

整合流水线

这里我打算花点时间聊聊非常重要的一点:我们的 Redis 连接(通过 StackExchange.Redis)并整合为一条流水线。大家可以把它想象成一条传送带,每个人都可以向其中添加点什么,之后它会转到某个地方,最终再转回来。在第一项传送物品抵达终点或者被发回期间,我们可以已经向流水线添加了成千上万的其它内容。但如果一次性添加的物品过大,大家就得等它先处理完成,之后才能继续添加其它物品。

这些内容可以是独立的,但传送带本身是共用的。在我们的示例当中,传送带就是连接,而物品就是命令。如果有大规模负载进入或者传回,那么传送带就会被暂时占用。具体来说,如果当前大家正在等待某项结果,但有个讨厌的大玩意把整条流水线堵塞了一、两秒,就有可能造成负面影响——这就是超时。

我们经常看到有人建议向 Redis 注入少量多次负载,用以降低超时问题的发生机率;但这么干其实没什么用——除非您的流水线非常非常快。这种作法虽然减少了超时现象,但也只是把超时换成了排队等待。

最重要的,是要意识到计算机中的流水线就像现实世界中的流水线一样。无论最窄处在哪里,这都是约束通量的瓶颈所在。只不过计算机中的流水线是一种动态通道,更像是一条可以扩展、弯曲或者扭结的软管,因此其瓶颈也不是一成不变的。它的瓶颈可能是线程池耗尽(注入了太多命令,或者是需要发出太多结果),可能是网络传输带宽,也有可能是某些东西影响到了我们对网络带宽的使用。

需要注意的是,在这样的延迟级别之下,我们不能再以秒作为看待问题的时间单位。对我来说,我考虑的不会是每秒 1 Gb,而是每毫秒 1 Mb。如果我们能够在大约 1 毫秒或者更短的时间内完成网络传输,则证明该负载确实很重要,会带来能够实际测量到且具备现实影响的传输时耗。换言之,关注延迟时尽量从小单位入手。在处理较短的持续时长时,我们必须把系统限制与同一持续时间内的相对约束条件进行等比例比较。面对毫秒级的延迟,再去谈那些通常以秒为单位的计算概念及指标,恐怕只会搞乱我们的思维以及决策方式。

流水线:重试

流水线自身的性质,使我们很难自信地使用重试命令。在这个残酷的世界里,我们的流水线更多变成了机场里运送行李的传送带——缓慢,而且只能等待。

大家可能都遇到过这样的情况,我们去机场,早早办好了行李托运,看着自己心爱的小箱子消失在传送带尽头。这时,我们突然发现候机厅旁边就有更划算的行李运送服务站。没问题,机场服务人员超有耐心,答应把行李退回来,转交给外面的第三方托运商。然后……行李不见了。去哪儿了?没人知道。

也许我们的包裹被交到了地勤管理员手上,但在回程的时候不知哪去了——也有可能连地勤那关都没到。我们不知道,我们也不清楚该不该重新发送一次。如果再发一次,包裹还是消失了,该怎么办?对方知不知道我们的担心?我们不想把情况弄得太复杂,但现在我们确实感到困惑而无助。好了,下面我们来聊聊另一个问题:缓存失效。

缓存失效

在前文当中,我一直反复提到清理,缓存清理是如何起效的?Redis 拥有一项发布 / 订阅功能,您可以在这里推送消息,所有订阅方都将收到您的消息(消息也会发送至所有副本位置)。利用这个简单的概念,我们可以轻松建立起一条订阅缓存清理流水线。当我们打算提前删除某个值(而非等待 TTL 自然消失)时,我们只需要将该键名发送至流水线与监听程序(比如事件处理程序),即可将其从本地缓存当中清理出去。

具体步骤包括:

  1. 通过 DEL 或者 UNLINK 将该值从 Redis 当中清理出去。或者,利用新的值替代该值。
  2. 将该键广播至清理频道。

其中的顺序非常重要,因为顺序错乱会发生争用,最终甚至有可能重新获取旧值。请注意,我们并没有推送新值。我们的目的并不是推送新值。我们所做的就是在需要时,从 Redis 处获取该值。

将一切组合起来:GetSet根据前文所述,我们的操作基本上可以归纳为以下形式:

复制代码

varval= Current.SiteCache.Get<string>(key);

if(val==null)

{

val= FetchFromSource();

Current.SiteCache.Set(key,val, Timespan.FromSeconds(30));

}

returnval;

但是,我们可以大大改进具体方式。首先,这里有不少重复,更重要的是代码会在缓存过期时产生数百项同时针对 FetchFromSource() 的调用。如果负载强度过大,该怎么办?而且根据之前选择使用缓存的情况来看,其强度显然不可能太小。所以,我们需要更好的计划。

下面来看最常用的方法:GetSet ()。好的,命名是个难题,很多人可能已经想打退堂鼓了。那我们到底想在这里实现什么目标:

  • 如果存在,获取该值。
  • 如果值不存在,则计算或者获取该值(并将其注入缓存当中)。
  • 避免对同一值进行多次计算或者获取。
  • 确保尽可能缩短用户的等待时长。

我们可以使用一部分属性来实现优化。假设您现在正在加载网页,或者是 1 秒或 3 秒之前开始加载,这很重要吗?Stack Overflow 中的问题及结果是否会发生很大变化?答案是:确实会发生变化,但并不一定很重要。也许您只是参与了一项投票、进行了一项编辑、或者发布了一条评论。对于这些能够引起用户关注的部分,我们需要利用缓存确保其及时更新。但对于正在同时加载该页面的数百位其他用户而言,这一点差别就显得无足轻重了。

也就是说,我们拥有一定的浮动空间。下面,就让我们利用这点空间改善性能效果。

以下为目前我们 GetSet 中的内容(是的,另有一个等效异步版本):

复制代码

public static T GetSet<T>(

this ImmediateSiteCache cache,

string key,

Func<T, MicroContext, T> lookup,

int durationSecs,

int serveStaleDataSecs,

GetSetFlags flags = GetSetFlags.None,

Server server= Server.PreferMaster)

其中的关键参数为 durationSecs 以及 serveStaleDataSecs。调用则通常如下所示(这里假设一个简单例子以供讨论):

复制代码

var lookup = Current.SiteCache.GetSet<Dictionary<int,string>>("User:DisplayNames",

(old,ctx) =>ctx.DB.Query<(intId,stringDisplayName)>("Select Id, DisplayName From Users")

.ToDictionary(i=>i.Id),

60,5*60);

此调用指向 Users 表,并对 Id -> DisplayName 查找进行缓存(我们实际上并不会这么做,只是作为简单示例)。其中最重要的部分在于末尾处的值。我们假定“缓存周期为 60 秒,但服务持续为 6 分钟。”

如此一来,在 60 秒周期之内,任何针对该缓存的命中都会返回相应值。但该值在内存(以及 Redis)的驻留时长总计 6 分钟。在 60 秒与 6 分钟之间(从缓存时开始),我们将一直为用户提供该值。但我们同时也会在另一线程上启动后台刷新,以便未来的用户能够获取新值。清理,而后重复这个过程。

这里的另一个重要细节,在于我们会保留一份每服务器本地锁表(一个 ConcurrentDictionary ),它负责阻止两次调用同时运行 lookup 功能并试图获取该值。举例来说,我们不允许 400 位用户同时对数据库进行 400 次查询。用户 2 到用户 4000 最好是等待首次缓存完成,这样我们的数据库才不会被瞬间袭来的请求所吞没。为什么要用  ConcurrentDictionary <string, object> 来代替……比如说 HashSet ?因为我们希望锁定字典中的该对象以供后续调用者使用。他们都在等待相同的提取结果,而该对象就代表着我们的提取内容。

下面来聊聊 MicroContext,它主要是为了配合多租户机制。由于提取有可能发生在后台线程上,因此我们必须了解其具体用途。提取指向哪个网站?哪个数据库?此前的缓存值是什么?我们需要将这些传递给后台线程,以确保在提取新值之前先对这些上下文信息进行参考。传递旧值还让我们能够根据需要处理错误情况,例如在发生记录错误时,返回旧值——或者说稍微过时的数据——总比直接显示错误页面好得多。当然,某些场景下返回旧值可能更糟,那大家就别这么做。

类型与事物

我经常被问及的一个问题,就是我们如何使用 DTO(数据传输对象)。简而言之,我们不用 DTO。我们只在必要时使用额外的类型与分配机制。举例来说,如果我们可以在 Dapper 中运行.Query (“Select…”); 并将其注入缓存,那我们肯定优先选这种简单方法。很明显,没有理由为了缓存而刻意创造额外的类型。

但如果有必要对数据库表进行 1:1 缓存(例如 Posts 表中的 Post,或者 Users 表中的 User),我们当然会缓存。如果某个子类型或者事物组合属于组合查询当中的列,我们只需要将.Query 作为该类型,从这些列中获取填充内容,然后再缓存即可。说得可能比较抽象,下面来看具体的例子:

复制代码

[ProtoContract]

publicclassUserCounts

{

[ProtoMember(1)]publicintUserId {get; }

[ProtoMember(2)]publicintPostCount {get; }

[ProtoMember(3)]publicintCommentCount {get; }

}

publicDictionary<int, UserCounts>GetUserCounts()=>

Current.SiteCache.GetSet<Dictionary<int, UserCounts>>("All-User-Counts", (old, ctx) =>

{

try

{

returnctx.DB.Query<UserCounts>(@"
{1}
Select u.Id UserId, PostCount, CommentCount
{1}
From Users u
{1}
Cross Apply (Select Count(*) PostCount From Posts p Where u.Id = p.OwnerUserId) p
{1}
Cross Apply (Select Count(*) CommentCount From PostComments pc Where u.Id = pc.UserId) pc")

.ToDictionary(r => r.UserId);

}

catch(Exception ex)

{

Env.LogException(ex);

returnold;// Return the old value

}

},60,5*60);

在这个例子当中,我们使用到 Dapper 的内置列映射功能。(请注意,其设置了仅 get 属性)。该类型专门用于解决这类需求。例如,其甚至可以为 private,并将具有 Dictionary<int, UserCount> 的 int userId 作为一项方法内细节。我们还显示了 T old 以及 MicroContext 的使用方法。如果发生错误,我们会记录下来并返回之前的值。

下面是类型。是的,只要是能起效的方法,我们就会使用。我们的理念是除非有用,否则不创造更多类型。DTO 不仅会带来更多类型,同时也包含很多映射代码——或者其它一些功能性代码(例如反射)。这些代码可能会意外中断。所以,保持简单最重要,我们也始终以简单为原则。简单意味着分配与实例化需求更少,也让性能得以更上一层楼。

Redis:必要的类型

Redis 提供一系列数据类型,其中所有键 / 值都使用“String”类型。但是,这里大家不能让 String 视为字符串数据类型,这与一般的编程习惯不同(例如.Net 中的字符串或者 Java 中的字符串)。这里的 String 代表的是 Redis 中的“一些字节”,其可以是字符串、可以是二进制图像,也可以是一切您能够以字节方式存储的信息!但是,我们也通过各种方式使用其它几种数据类型:

  • Redis Lists 对于聚合器或者账户操作这类队列机制非常有用,擅长按顺序执行操作。
  • Redis Sets 特别适合某些唯一的条目列表,例如“本次 alpha 测试涉及哪些账户 ID?”(这些内容唯一但没有特别的顺序要求。)
  • Redis Hashes 主要用于类似字节的内容,例如“站点的最新活跃日期是哪天?”(其中哈希键 ID 为站点,值则为日期)。我们利用它来确定“我们这一次需要在站点上运行横幅吗?”之类的问题。
  • Redis Sorted Sets 适用于处理有序内容,例如存储每条路径中速度最慢的 100 项 MiniProfiler 跟踪记录。

谈到有序集,我们一直打算将 /users 页面替换为具有范围查询的有序集(每个特定时间范围内一个集合),但每次开会都会忘记……

监控缓存性能

再来聊聊其它值得亲耐滴诉问题。还记得之前提到的延迟因素吗?直接访问的速度非常慢。我们对问题页面进行首次渲染时平均需要 18 至 20 毫秒,调用 Redis 则需要大约 0.5 毫秒。不过大量调用累加起来,其实际延迟很快就会达到与渲染时间接近的水平。

首先,我们希望在页面级别关注这个问题。为此,我们使用 MiniProfiler 查看页面加载当中涉及 Redis 的每一项调用。MiniProfiler 还与 StackExchange.Redis 的分析 API 对接。下图所示,为问题页面的实际效果示例,顶部位置记录了其实时跨网络计数:

接下来,我们希望密切关注 Redis 实例。为此,我们使用了 Opserver。以下为相关示例:

我们在这里部署了一些内置工具,用于分析键使用情况以及通过正则表达式模式对其分组的能力。如此一来,我们就可以将我们的缓存内容与数据结合起来,看看哪些内容占用的空间最大。

备注:此类分析的运行应仅在辅助节点上进行。在默认情况下,Opserver 会立足副本运行此类分析,同时阻止相关任务被运行在主服务器上。

接下来是什么?

.NET Core 是我们在 Stack Overflow 的未来发展平台。我们已经将众多支持服务移植过去,目前正在开发各主要应用程序。老实说,缓存层当中并没有多少缓存,但最有趣的是 Utf8String (还没有部署)。我们的总缓存量很大,不同的信息被缓存在多个不同的位置——例如边栏中的“相关问题”。如果这些缓存条目为 UTF8,而非.NET 默认的 UTF16,那么其大小就能减少一半。这一点非常重要,毕竟我们的整体业务规模相当可观。

案例

我曾问过,Twitter 那边的员工遇上突然发生大量缓存故障的情况时,会如何应对。其实挺有意思的,下面我会尝试复述整个故事:

Redis 危机:边抢救边施压

有一次,我们的 Redis 主缓存量达到 70 GB 左右的水平。要知道,服务器上的总缓存量也只有 96 GB。当时我们看到占用量仍在随时间推移而增长,因此我们打算进行服务器升级并进行转换。当准备好硬件并进行故障转移时,旧设备的缓存使用数字已经达到约 90 GB。嘭,关机,新设备启动——我们成功了!

但真有这么冬日吗?这部分工作不是由我负责,但我也有参与规划。我们当时没考虑到 Redis 中的 BGSAVE 会发生内存分叉(至少在 2.x 版本中还没考虑到)。我们当时还挺开心的,觉得能及时做好准备是个了不起的成就。那天周末,我们按下按钮,将数据复制到新服务器,并准备以故障转移的方式进行系统切换。

之后,所有网站就立刻下线了。

当时内存分叉的情况是,迁移期间变更的数据都会被复制到影子服务器内,这部分内容在克隆完成前都不会发布……因为我们需要服务器处于稳定状态,并对从那时起出现的所有变更进行初始化,而后复制到新节点中(否则我们会丢失这些新变更)。因此,新变更发生的速度也就是复制期间内存增长的速度。很快容量就达到了 6 GB,接着 Redis 崩溃,Web 服务器失去了 Redis 的支持(多年以来我们从没见过这样的情况)。

因此,我马上给团队打了电话,并利用新服务器与空缓存让网站重新上线。需要强调的是,Redis 本身并没有任何问题,问题出在我们这里。十年以来,Redis 一直稳定可靠,它是我们使用过的最稳定的基础设施之一……稳定到甚至感受不到它的存在。

好了,下面来看另一个教训。

没有使用本地缓存

说起这个故事,可能某位开发人员要在心里暗暗骂我了。但我得强调,我绝对没有任何恶意,咱们只说具体情况。

当我们从 Redis 的本地 / 远程双层缓存中提取某个缓存值时,我们实际会发出两条命令:一条键获取命令,以及一条 TTL 命令。TTL 的结果会告诉我们 Redis 已经将该值缓存了多少秒……我们会直接把该时间设定为本地服务器内存缓存周期。以前我们曾经通过库代码使用 -1 标记值来表达没有 TTL 的情况。对于“无 TTL”,我们在重构中转而使用 null 语法……但接下来就出现了布尔逻辑错误。仍然以之前提到的 Get 为例,以下简单语句:

复制代码

if(ttl != -1)//...push into L1
被转化为:
if(ttl== null)//...push into L1

但我们的大多数缓存都拥有一项 TTL。这意味着绝大多数键(占比可能高于 95%)不再在一级缓存(本地服务器内存)中缓存。每一次调用这些键,都会转到 Redis 并返回结果。Redis 拥有极高的弹性与速度表现,因此我们在几个小时内都没注意到这个问题。后来,我们将实际逻辑更正为:

复制代码

if(ttl !=null)// ... push into L1

然后就一切正常了。

将页面缓存周期设定为 0.000006 秒

这个数字没写错。2011 年,我们在页面级输出缓存中发现了一些代码:

复制代码

.Duration=newTimeSpan(60);

它想要表达的含义其实是将缓存周期设定为 1 分钟。如果 TImeSpan 的默认构造函数采用秒为单位,那么一切都毫无问题。

但结果并非如此,我们发现内存使用量只增加了一点点,而 CPU 使用量也开始上升。

好心办坏事

多年来,我们一直对大多数页面进行输出结果缓存。其中包括主页、问题列表页面、问题页面本身以及 RSS 馈送。

请注意:在缓存时,大家需要根据缓存类别对缓存键进行更改。具体来说,我们需要考虑:是否匿名?是否移动?压缩、gzip、还是不压缩?实际上,我们不能也不应该对登录用户的输出内容进行缓存。我们的统计信息位于顶端,而且面向每位用户。一旦缓存,我们会发现不同页面视图间的显示内容存在冲突。

无论如何,我们发现这些缓存类型在处理过去两周内 80% 的问题时,都存在命中率过低的问题。真的很低。但内存占用量却非常高(高到中心直接在大对象堆上运行)。此外,垃圾回收机制的清理成本也相当可观。

事实证明,这两项要素非常重要,不加以调整则会导致缓存反而拖累系统性能。我们从偶尔命中的缓存系统内获得的性能提升,远远低于建立以及清理缓存带来的性能成本。这一点让我们非常困惑,但在具体观察相关数字时,问题的核心逐渐清晰起来。

过去几年,Stack Overflow(以及所有问答站点)的输出结果都没有进行缓存。ASP .NET Core 中也不存在输出缓存机制,因此请不要使用这种方式。

目前,我们仍然需要在某些 RSS 馈送路由上对完整的 XML 响应字符串进行缓存(类似于输出缓存,但没有实际使用输出缓存)。这是因为此类路由的命中率很高。该特定缓存仍具有之前提到的所有缺点,但极高的命中率让我们决定做出妥协。

意识到现实世界要比我们的想象更疯狂。 当.NET 4.6.0 刚刚问世时,我们发现了一个错误。我当时正在深入研究 MiniProfiler 为什么没有进行首页面本地加载,错误的发现让我们猝不及防。

这项 bug 出现在缓存层当中,具体取决于问题的性质以及尾调用的影响。总结来讲:系统不会根据我们传入的参数进行方法调用。这意味着我们只能使用随机的缓存持续时间。幸运的是,这个 RyuJIT 问题在下一个月就得到了热修复。

.NET 4.6.2 缓存响应周期长达 2017 年。好吧,这个并不是服务器端缓存的问题,但我觉得很有趣所以一并算进来。 在部署.NET 4.6.2 之后不久,我们注意到客户端缓存以及 CDN 缓存增长都出现了某些异常。事实证明,.NET 4.6.2 当中存在一项 bug。

原因很简单在将代表“现在”的 DateTime 值与应该到期的缓存响应时间进行比较,并计算二者差值以获取 Cache-Control 头中 max-age 部分时,该值会因为一系列原因而被重置为“现在”值——即差值为 0。所以我们假设:

复制代码

2017-04-0501:17:01(cache until) -2017-04-0501:16:01(now) =60seconds

然后,假定“现在”值被替换为 0001-01-01 00:00:00…

复制代码

2017-04-0501:17:01(cache until) -0001-01-0100:00:00(now) =63,626,951,821seconds

幸运的是,这里的计算非常简单,我们相当于要求浏览器将该值缓存 2017 年 4 个月 5 天 1 小时 17 分钟 1 秒。很明显,无论是在浏览器上还是 CDN 中,这么长的缓存周期都属于严重错误。

我们没能及时发现这个问题,现在设置已经部署在生产环境中,而且回滚无法快速解决问题。我们该怎么办?

还好我们已经开始使用 Fastly,其中采用 Varnish 与 VCL。因此,我们可以在那里检测这些疯狂的 max-age 值并将其覆盖为正确值。在第一次推送时,我们没有注意到 Fastly 上缓存键常规哈希算法中的某个关键部分,因此导致用户在尝试加载某个问题时会重复验证其登录身份。很抱歉,几分钟后我们修正了这个问题,并确定代码能够正常运行。

干扰 Redis 正常运行

我们还遇到过这样一个问题,即主机本身会中断 Redis 并引发能够明显感觉到的流水线超时。我们观察了一下 Redis:这种慢速现象是随机的。与之相关的命令也没有任何共通的模式或者关联(例如包含大量小键)。我们又看了看客户端与服务器这边的网络与数据包跟踪记录,都很正常——看起来,暂停问题源自 Redis 主机内部的相关计时机制。

那么……答案是什么?在经过大量手动分析与故障排查之后,我们发现主机上的另一个进程总会同时出现利用率飙升。虽然这峰值不算高,但引发该峰值的理由非常重要。

事实证明,监控进程会启动 vmstat 以获取内存统计信息。虽然具体启动频率很正常,很合理,但 vmstat 会将 Redis 从其正在运行的 CPU 核心上移除,而且移除哪一个实例是随机的。这种面向另一核心的切换,使我们观察了 Redis 频繁超时的具体现象。

在发现了问题之后,我们首先想到设备当中其实拥有充足的计算核心,因此我们决定将 Redis 固定至物理主机上的特定核心。这将确保服务器的主要功能始终具有运行优先级,而监控则属于次要负载。

后来我了解到,Redis 现在引入了内置的延迟监控机制,推出于 2.8 版本后期。

缓存常见问题

我也遇到过不少常见但很难归类的问题,我想这里不如把它们整理成一份常见问题合集。在后续更新中,我也可以把更多有趣的问题添加到这份清单当中。

为什么不使用 Redis Cluster?

A: 主要原因有以下几点:

  1. 我们需要使用数据库,而 Cluster 当中并不提供数据库功能(这是为了控制消息复制头的大小)。我们可以将数据库 ID 移动到缓存键中来解决这个问题(正如之前提到的本地缓存方法),但在使用大型数据库时,我们总得在维护方面做出权衡——例如弄清楚哪些键占用的空间最多。
  2. 到目前为止,其复制拓扑为节点到节点,这意味着主集群上的维护工作需要在灾难恢复数据中心的辅助集群上采用相同的移动拓扑。这会提高维护工作的难度。我们正在等待集群到集群复制功能的推出。
  3. 需要 3 个以上的节点,集群才能正常运行(出于选举机制等考虑)。我们目前各个数据中心只运行 2 台物理 Redis 服务器,其中只有 1 台服务器的性能高于我们的需求,另一台专门用作副本 / 备份。

为什么不使用 Redis Sentinel?

A: 我们正在考虑,但其整体管理难度并不比我们现在更低。虽然将端点连接起来并进行定向的想法很棒,但管理工作会非常复杂;由于 Redis 本身非常稳定,所以我们没必要改变现有的策略。Sentinel 最大的问题之一,在于其会将当前拓扑状态写入同一配置文件。这对采用配置托管的用户来讲不太友好。例如,我们现在使用的是 Puppet,每次运行时文件变更都会出现问题。

如何保护 Stack Overflow 团队版的缓存?

A: 我们一直维护着一套隔离网络以及独立的 Redis 服务器以保障私有数据。Stack Overflow 企业版的客户都拥有自己的隔离网络与 Redis 实例。

如果 Redis 宕机了,您会怎么办?

A: 首先,我们的数据中心拥有一套备份。但如果假设备份也崩溃了,那只能面对最差的结果。如果没有 Redis,我们在重启应用程序时会有点慢,冷缓存时性能会比较差,SQL Server 会受到一定影响,但这些逐渐都会过去。如果 Redis 宕机(或者说稍后才能上线),其实问题自己就会慢慢解决,而且不会造成什么数据丢失问题。我们已经将 Redis 视为本地开发的可选项,因为其早在其它基础设施组件之前就已经存在,而且直到今天也仍然属于可选项。它不是任何事情的事实来源,其中包含的所有缓存数据都可以从源代码中重新获取。Redis 中的队列包含账户合并类型操作(以亚秒级单位执行,因此属于短队列)、聚合器(将网络事件记录至我们的中央数据库内)以及一部分分析操作(暂时丢失 A/B 测试数据并不是什么大问题)。所有这些都是问题,但都不是太严重。

Redis 数据库有什么缺点吗?

A: 当然有,我自己就发现了一点。虽然上限很高,但它最终还是会影响到性能指标。当 Redis 过存储键进行过期处理时,它会遍历数据库以查找并清理这些键——大家可以理解成一次全方位的“命名空间”检查。最重要的是,这种宏观循环一直存在。由于每 100 毫秒运行一次,因此其会对性能造成不小的影响。

您打算对“一级”/“二级”缓存的实现方案进行开源吗?

A: 我们一直在考虑,但目前主要有以下障碍:

  1. 目前的方案个性程度太高。这是一套高度面向多租户的方案,可能并不适合很多朋友的具体需求。这意味着我们必须拿出一些精力重新设计 API。我们希望把这组 API 直接放进 StackExchange.Redis 客户端,或者将其构建成独立的库。
  2. 另一个想法是让 Redis 服务器自身拥有更高的核心数量支持能力(用于处理更多发布 / 订阅负载)。这项能力即将在 Redis 版本 6 当中出现,因此我们可以大大减少发布 / 订阅机制的自定义工作量,并使用更多标准化的实现方法。这种定制化的因素越少,我们的方案才能越适合更多人的需求。
  3. 时间。我希望我们能有更多充裕的时间,时间才是最宝贵的东西。

在流水线当中,您如何处理大型 Redis 负载?

A: 在这方面,我们使用一条独立的“bulky”连接。其拥有更高的超时时间,而且使用频率很低。接下来的问题,在于是否有必要把 bulky 引入 Redis。如果某个大型条目值得缓存,但提取成本又太高,我们可能就不会使用 Redis,而倾向于利用“本地缓存”为多台 Web 服务器进行多次提取。每用户功能(问答类用户会话其实具有很高的 Web 服务器粘性)可能也适合用于处理这类问题。

如果有人在生产环境中运行 KEYS,结果会如何?

A: 我可能会暴跳如雷。不过说真的,在 Redis 2.8.0 上,我们至少可以使用 SCAN——它不会把 Redis 彻底堵死,而只是以块的形式进行键转储,同时允许其它命令继续执行。KEYS 则可能导致生产拥塞——这里说“可能”恐怕不太准确,应该是 100% 会在我们的业务规模下引发拥塞。

如果有人在生产环境中运行 FLUSHALL,结果会如何?

A: 这绝对属于刑事案件,毫无疑问。Redis 6 正计划添加 ACL,用以限制可疑操作池。

警方调查人员要如何弄清上述情况?他们如何就延迟峰值进行取证?

A: Redis 拥有一项出色的功能,名叫 SLOWLOG,它会在默认情况下记录所有持续时间超过 10 毫秒的命令。我们可以进行具体调整,但由于一切正常操作应该都能快速完成,所以我们会继续使用 10 毫秒这个标准。运行 SLOWLOG 时,我们可以看到最后 n 个条目(可对 n 进行配置)、命令与参数。Opserver 能够在实例页面上显示这些内容,以帮助我们轻松找到坏家伙。当然,问题可能源自网络延迟或者主机上自然发生的 CPU 峰值 / 争用。(我们使用处理器关联以固定 Redis 实例,以避免出现资源争用问题。)

是否在 Stack Overflow 企业版中使用 Azure Cache for Redis?

A: 在用,但可能不会长期使用。在为测试环境创建这样的缓存时,配置周期实在是太长了。不开玩笑,通常需要几十分钟甚至一个小时。我们后续打算使用容器,这不仅能够加快速度,还能帮助我们在各类部署模式之间实现版本控制。

每位开发人员都有必要了解缓存决策中的这么多细节吗?

A: 当然没必要,这里涉及的具体考量与指标太多太杂了。在我看来,开发人员只需要对缓存层的概念以及相对成本有那么一点了解就可以。正因为如此,我才在这里多次强调数量级单位,开发人员应该借此考量成本 / 收益评估单位,并学会如何选择缓存位置。大多数业务每天都不需要面对数亿次的点击量,因此也就没有太大的优化决策压力——毕竟总量不会很大。这里我只聊自己在 Stack Overflow 遇到的实际问题,外加自己的解决思路,希望能够给大家带来一点启示。

最后,我想整理一下本文中提到的各类工具,外加用于支持缓存系统的其它方案:

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章