Microsevices陷阱: 层级演进(中)
5. 缓存
当某个操作的结果可以被临时存储,从而使后续的相同操作能够直接采用上述结果,而非花费同样时间和资源在重复操作上,这种性能优化方法就是缓存技术。如今面临大规模的HTTP访问量,缓存已经成为解决性能瓶颈的大杀器之一。
当然,即使是单一系统,目前也存在众多缓存方案可供选择。但对微服务架构来说,可选择的就更多——问题是对于分布式系统而言,由于节点间的相互对等性,缓存存在于客户端或是服务端,会是一个艰难的选择。
客户端、代理和服务端缓存
对于三个主要的缓存承载对象之一,客户端缓存主要是把数据放在客户端中,并由客户端决定是否从服务端刷新数据。一种理想的情况是,由服务端负责提供是否需要刷新的线索,然后由客户端决定是否刷新,这种技术被广泛用于HTTP缓存。
代理是指位于客户端和服务端之间的设施,由代理决定缓存机制的包括反向代理或CDN技术。
另一种方法是由服务端负责缓存技术,如采用Redis或Memcache,或者更简单的一些片内缓存技术。
对上述方案的选择主要取决于优化的目标。例如,客户端缓存主要目的是降低网络负载,并且实现起来最为简便;缺点是一旦实行成熟的客户端缓存机制,服务端要想做出什么修改就比较困难,特别是容易引发未经验证的脏数据问题(下文会做进一步讨论)。而对代理缓存来说,其对客户端/服务端都是完全透明的,且针对普通HTTP通信实现也很简单,例如采用反向代理如Squid或Varnish。尽管代理会引入更多网络路由节点,但这对随后的性能提升来说基本是可以忽略不计。服务端缓存能够保证对客户端实现透明化,而且一旦缓存位于服务边界上,就能很容易解决脏数据、分析和优化命中率。同时服务端缓存对于系统性能的提升是最为有效和迅速的——但实现起来又稍复杂。
一般缓存技术的选择可以是混合性的,但也有分布式系统内部完全不设缓存的情况,因此要具体问题具体分析。
HTTP缓存
HTTP协议提供了全面的缓存技术,包括客户端和服务端。首先,HTTP头的cache-control指令告诉客户端是否应该缓存当前响应信息,以及缓存时间。同样,还包含一个Expires属性,用于标记该信息在某个时间点后过期,而非持续多长时间。对于静态网站数据,如图像、CSS等,使用cache-control的time to live(TTL)就能够很好解决这一问题。
除了cache-control和Expires,HTTP还提供了一种Entity Tags(ETags)机制。ETag其实是用来标记当前信息是否已经发生变化,即使URI不变,也能识别该信息是否是最新的。应当明确,ETags本身不提供缓存机制,而是通过Conditioanl GET实现,后者是说在GET请求中添加某些头信息,从而告诉服务端只有当该条件满足时才返回整个信息。
当然,ETags还少不了要打上cache-control,只有当TTL或Expires满足时,客户端才会发送信息并携带If-None-Match: ETag值。服务端通过读取If-None-Match信息,判断客户端的信息是否已经过时,如果已是最新,则返回304 Not Modified,否则返回整体信息和200 OK。这些方法在反向代理上被广泛采用,甚至包括CDN如AWS的CloudFront和Akamail。一般的HTTP标准库也都包含这些功能。
无论是cache-control、Expires或ETags,其在功能上相互存在一定重叠。而这也确实会对HTTP通信带来一些问题,特别是混合使用时。建议参考《REST in Practice》或HTTP/1.1协议的13章。
即使不采用HTTP协议进行消息缓存,也应当参考这部分的设计思路,无论对客户端/服务端的缓存实施都是很有价值的。
写缓存
对写操作设置缓存在某些场景下是必要的,例如当写操作突然增加时,短期间内频繁I/O会降低系统性能,好的做法是先把数据写入本地缓存中,之后再统一更新至下游节点。另一方面,当下游服务发生不可用的情况时,写操作的数据会被较好的保存,并不会造成数据丢失的问题。
恢复缓存
缓存也能够被用于灾难恢复。例如对客户端缓存来说,当下游节点出现问题,客户端就可以把本地缓存作为临时数据,而不是直接导致不可用。Web技术早起曾经有一种Guardian技术,作用是定期生成整个网站的静态内容,当系统离线时就采用静态版本提供服务,尽管数据不是最新的,但仍可以向用户提供服务。
隐藏源
由于未命中缓存的存在,一般系统的源都会面临未缓存请求的访问,当请求数量突然增加时就可能导致源崩溃,继而影响整个分布式系统。因此,在某些系统中,请求永远不能直接访问源,当缓存未命中时会立即得到错误响应,同时缓存节点也会把相应的请求信息异步发送至源,再由源把所需数据同步至缓存节点。这种做法的好处就是完全避免了源遭遇大访问量的情况,从而提高系统整体稳定性。
缓存建立的原则
目前业界有一种趋势,就是多级缓存+各类缓存。实际上,缓存就意味着脏数据的可能:当缓存层级增加,脏数据的概率和数量也会随之增加,对于某些业务场景而言这是不可容忍的。特别是当采用微服务架构,类似后果的影响会被无限放大。因此采用缓存的原则就是:简单、单一、有效。
另一方面,采用缓存要时刻保持警惕,因为脏数据对某些业务场景来说几乎是零容忍的。更何况不同缓存所带来的约束也各不相同——例如HTTP缓存中的Expires: Never,就可能导致某些用户理论上永远无法更新数据,其后果可想而知。因此,一定要在完全理解概念的基础上,再谨慎引入缓存技术。
6. 自动扩展
自动扩展的前提是基础设施的全面自动化,此类问题在前几篇文章已经提到多次。但是,仅有运维自动化还不足以实现自动扩展——应用至少应部署在公有/私有云之上。例如,在一天中的某个时间段内系统负载总是面临峰值考验,如采用AWS公有云服务的话,动态开启/关闭节点可以节省成本,同时还能在面临高负载时应对自如,当然前提是你手中有足够的运营数据。这种根据数据决定基础扩展策略的方式,通常被称作预测式扩展。
另一种自动扩展方法是反应式扩展。如果某个节点出现问题,或系统负载突然达到一个危险的峰值,则自动启动新节点以增强吞吐能力。反应式方法的关键在于发现问题、启动节点的速度,当然一方面是靠团队Devops的能力,另一方面还要得到云服务商的支持。
实践证明,无论是预测式扩展,或是反应式扩展,其对提高系统整体可用性都是非常有效的。这里的建议是,当团队初次涉足该领域时,应优先考虑设置节点失败条件下的反应式扩展功能,然后再根据更多数据采取进一步行动。
7. 分布式系统与CAP原则
十多年来,CAP原则成为构建分布式系统时不可逃避的话题。尽管CAP原则在其科学性上倍受质疑,但其作为权威的实践经验仍不失指导性。
CAP分别表示一致性、可用性和分区容错性。一致性是指任何节点上的数据都是保持一致的;可用性意味着任何请求都会得到响应;而分区容错性指当系统内部通信出现问题时,依然有能力保证整体可靠性。
CAP原则的基本内容就是,分布式系统始终无法同时囊括上述三种属性,多数情况下只能择其二设计。在实践中,与CAP原则相符的例子比比皆是,假设一个分布式系统包含A、B两个实例,后台数据存储在Ad和Bd两个Replica数据节点上。当Ad和Bd间的网络通信中断时,如果不关闭A或B的其中一个,则Replica的数据就会无法实现同步,从而造成不一致。而如果关闭实例A或B,则会降低系统可用性。这就显示出一致性和可用性之间的矛盾——分布式系统无法放弃分区容错:因为其本身就是为此而生。
AP系统
如果强调可用性,就意味着一致性可能出现问题。一般情况下,如果多个数据节点间发生不一致,也可以在某个时间后使其交换数据以达到同步,而非立即实行。这就是强调多次的最终一致性。最终一致性系统预示着用户将有可能收到脏数据,这就要建立在具体的业务需求基础上了。
CP系统
有时我们不可避免地面临强一致性,即使因为无法达到一致而被迫拒绝服务——这就是牺牲可用性的强一致性系统。然而,在分布式领域,稳定可靠的强一致性系统实现非常困难,一方面是由于分布式事务操作的低效性,另一方面是实现分布式锁算法的困难性(即使是单进程锁,要踩的坑也很多)。因此如果确实要构建强一致系统,采用现有成熟方案非常重要——因为它们通常都经历了时间的考验。当然对比前者,建立AP系统可能就要容易许多。
孰优孰劣?
AP和CP,选谁?前者只能实现“最终一致”,后者实现起来则“困难重重”。然而不能否认,在金融系统中,一致性几乎是必须的;而对大部分系统而言,AP则是更加现实的做法。要知道,在大多数情况下,选择AP和CP并非零和游戏,整个系统中可以同时存在AP和CP两种架构,以满足不同需求。要提醒的是,如果采用CP模式,调研好现有方案并择优采用是前提。AP则是更加具有普适性的大众选择。