天天看点

服务缓存设计指南(二): 正确使用缓存

作者:赵帅虎

上一篇文章我们介绍了服务缓存的基本概念和常见缓存策略,今天我们来看看如何正确地使用缓存吧。

什么时候用缓存

在合适的场景下,缓存能够极大地提升服务的性能、扩展性和可用性。一般情况下,数据量越大,访问这份数据的用户量越大,缓存的效果越好。在应对大流量的并发请求时,访问缓存比访问数据源,能够显著降低服务延迟,并支持更高的QPS。

常规的数据库(比如MySQL)可能只支持一定数量的并发连接。然而,如果从一个共享缓存读取数据,而不是从底层的数据,即便数据库的并发数已经被消耗完,client仍然能够正常获取数据。在数据更新频率不敏感的业务场景下,即便数据库服务挂了,client也能继续使用缓存里的数据。

我们推荐把高频读且低频更新的数据缓存下来,不推荐缓存敏感数据(比如权限验证信息)。

使用时,对于业务上绝对不能丢的数据,务必持久化到数据库。即便缓存服务挂了,服务仍然能够直接操作数据库,而不至于丢失一部分数据。

如何有效地缓存数据

为了保证缓存的有效性,关键点在于确定1)缓存什么数据、2)什么时候做缓存。

我们可以在第一次读取数据时把数据添加到缓存,这样服务只需要从数据库读取一次,后续的读请求均走缓存即可。

我们也可以提前把部分/全部数据加载到缓存,比较常见的是在服务启动阶段。不过,在大型系统中,我们不推荐这种方式,因为这可能导致数据库的访问量突增,导致服务不稳定。

所以选择哪一种呢?这时候我们需要对流量进行一些分析,协助我们判断是否对缓存进行预热,缓存什么数据。比如,一些用户每天都会使用应用程序,我们就可以把这些用户的静态数据缓存下来;但对于一周访问一次系统的用户,缓存就没必要了。

缓存尤其适用于不可变数据或变化频次很低的数据。常见的有电商场景下的商品信息、商品价格等,生成比较耗时的共享静态资源。在服务启动阶段,我们可以预加载一部分数据到缓存中,用来满足频繁的资源请求,提升系统性能。为了保证数据更新,可以启动一个后台进行,定期从数据库拉取最新的数据,更新缓存。还有一种比较复杂的方案,通过消息队列监听数据的变化,更新缓存。

对于动态变化的数据,缓存的效果相对比较有限,在一些特殊场景下除外(后面会详细说)。原始数据定期发生变化时,要么缓存中的数据很快就过期了,要么数据同步的代价降低缓存的效用。

我们不一定要缓存实体的所有信息,有些场景下只需要缓存特定的不可变字段,有时候只需要缓存过滤条件以方便获取ID。举个例子,一条数据代表一个有很多字段的对象,比如一个银行客户(字段有名字、地址、账户余额等),ta的名字地址通常是静态的,而账户余额则经常发生变化。这种情况下,可以只缓存这些静态字段,其余字段在需要时从数据库或其他服务获取即可。

预热缓存还是按需加载,还是全都要,我们更推荐让数据说话,使用的手段有性能测试、使用情况分析等。最终决定应考虑到数据的变化情况和使用情况。如果服务需要承载大量的请求,或是高度分布式的,缓存使用率和性能分析也十分有必要。比如在高并发的场景下,缓存预热可以降低高峰期数据库的压力。

缓存也可以用来避免重复的计算。如果一个服务调用需要处理大量数据,或者进行复杂的计算,我们可以将结果缓存起来。如果后续出现同样的计算,服务直接读缓存即可。

服务可以修改缓存里的数据,但存在一些副作用。我们不推荐把缓存作为一个持久存储使用,而是预设缓存里的数据随时可能丢失。千万不要把有价值的数据只放在缓存里,在数据库里务必也存储一份。一旦缓存失效或缓存服务挂了,我们也不会丢失数据。

什么情况下缓存频繁变更的数据

如果你频繁修改数据库里的数据,数据库的压力会比较大。举个例子,如果一个设备频繁地报告自己的状态和数据指标,如果应用层考虑到缓存会经常过期,选择不进行缓存;直接从数据库读写也会存在同样的问题,相当于把压力转移到了数据库上。

这种情况下,可以考虑把动态数据直接存储在缓存里,而不是放到数据库。考虑到这是非核心数据,也不需要进行审计,有一些变更没有被记录到也可以接收。

如何设计过期时间

大多数场景下,缓存里的数据是从数据库拷贝过来的,几乎一模一样。但数据缓存以后,数据库里的数据可能发生变化,导致缓存里的数据过期。很多缓存服务可以配置过期时间,以避免数据过期的时间太久。

数据过期后,缓存会把数据清理掉,下一次请求来的时候,服务必须从数据库重新获取数据(然后添加到缓存里)。我们可以给缓存服务设置一个统一的过期时间,也可以针对每一个key设置独立的过期时间。

有时候,缓存会被占满。这种情况下,把新数据写入缓存会导致已有的数据被删掉,这个过程叫缓存逐出。最常见的缓存逐出策略是LRU,当然也可以把逐出策略设置成不逐出,会导致新数据写入失败。最常见的Redis就提供了:

  1. noeviction: 不逐出、
  2. lru:最少使用算法,最长时间没有使用的数据
  3. random: 随机逐出数据
  4. lfu: 一段时间内使用频次最低的数据
  5. ttl: 到了过期时间的数据,与是否访问无关

让client侧缓存里的数据失效

client侧缓存里的数据通常是脱离应用服务的管辖范围,服务不能直接强制client侧添加或删除缓存里的数据条目。

如果client侧配置了一个很糙的缓存策略,缓存里的数据很可能是过期的。比如,如果缓存的过期策略没配置好,即便server侧数据早已经更新,client侧可能一直使用本地的过期数据。

缓存的并发读写问题

通常情况下,一个服务的多个实例共享一个缓存,每个实例都会读/写缓存里大的数据,产生了并发读写问题。考虑一个场景,应用程序需要更新缓存里的一条数据,但我们必须保证一个实例写入的数据不能被另一个实例的写覆盖掉。

考虑到可能的数据竞争,有两种更新策略:

  • 乐观锁:再更新数据的前一个瞬间,检查缓存里的数据在上次读之后是否发生过变化。如果没有变化,这次更新就是有效的。相反,应用程序需要根据业务逻辑判断是否执行此次更新。这个方法适用于数据变更不频繁或冲突不经常发生的场景;
  • 悲观锁:获取一个条数据时,给数据加锁,避免其他实例做变更。加锁确保了冲突不会发生,但会阻塞其他实例处理这条数据。悲观锁会影响技术方案的扩展性,比较适合耗时很短的操作。这种方法适用于冲突经常发生的场景,尤其是大量的数据被更新,必须保证变更的一致性;

缓存的最终一致性

在生产环境中,为了保证核心业务的稳定性,我们通常会使用读写分离的数据库方案。最常见的莫过于MySQL主从结构,比如一主二从的结构。上层应用写入/变更数据时,通常会访问主节点,读取数据时访问从节点。主从的数据同步借助于binlog机制,靠的是最终一致性。

一般情况下,这没什么问题。但涉及到短期大量的数据写入时,binlog同步会出现明显的延迟。设想一下,在应用程序和MySQL之间如果还有一层缓存,应用程序的一个实例 1)更新数据库; 2)将缓存置为过期;此时应用程序的另一个实例读取这条数据,触发一次cache miss,所以它MySQL从节点读取最新数据;但此时binlog同步还没有完成,所以读到了旧数据,记录在缓存中。我们做一个大胆的假设:

  1. 这条数据数据此后很长一段时间没有被更新过;
  2. 缓存没有设置过期时间,或过期时间很长;

那么后面很长一段时间,应用程序读到的都是旧数据,与实际不匹配。有什么解法呢?

最简单粗暴的解法是:读写都走主节点,这相当于把从节点给干废了,来一次单点故障,整个服务就挂了;

比较折中的解法有:

  1. 给缓存设置一个不长不短的过期时间,保证数据库压力不大,缓存也有效果;
  2. 只缓存不变化的字段,变化的字段从数据库取;

如果把最终一致性贯彻到底,可以做一个消费binlog写缓存的常驻任务,不过不建议自己写,最好复用公司的大数据体系(binlog2kafka,Flink SQL)。

继续阅读