设计服务时,我们通常使用缓存来提升系统的性能和扩展性。最核心的点是把频繁访问的数据拷贝到一个访问性能更好的存储上。如果这个更快的存储同时离服务实例非常近,比如专线连接的不同机房、同一个机房里的相同VPC、同一个机架上、甚至同一个Pod(k8s最小部署单元)里,缓存能够更快地提供数据,显著降低client端的响应时间。
缓存最有效的场景是:client端重复读取相同的数据,尤其是数据源符合下面四个条件时:
数据相对静态,不频繁变化;
读数据源比读缓存慢;
数据的并发读写对性能影响比较大;
数据源距离服务比较远,网络延迟高;
分布式应用通常会采用下面两种缓存策略:
使用私有缓存,数据通常缓存在服务实例的内存里;
使用共享缓存,通过实例和进程都可以访问该缓存,通常是一个缓存服务;
这两种策略下,缓存逻辑既可以放在client侧,也可以放在server侧。client侧缓存由提供用户交互的进程/线程来实现,比如网页浏览器或桌面应用。server侧缓存由运行在远端、提供业务逻辑支持的进程来实现。
最常见的私有缓存是一个内存缓存,它运行在进程的地址空间里,该进程中的代码可以直接访问缓存中的数据。这种类型的缓存访问速度最快,它可以存储中等数据量的静态数据。缓存大小通常与进程运行的机器/虚拟机/容器的内存相对应。
如果需要缓存的数据量超过了实例的内存大小,可以把数据写入本地文件系统。进程访问文件系统里的数据会比内存慢很多,但相对于通过网络从远端拉取数据,通常会更快一些。这种情况在近年来有一些变化,网卡速度和硬盘速度更多呈现你追我赶的态势,比如万兆网卡读取速度可达到10Gbps,换算一下1.25GB/s;而SSD的读取速度是500MB/s,高端的NVMe SSD读取速度可达到7000M/s。实际场景中,还需要结合成本、稳定性等多个因素考虑。
考虑到降本增效,内存磁盘混用也是比较常见的方案,但为了保证内存的命中率,避免太多请求落到磁盘上,算法上需要做很多优化。由于自己写难度太高,业界提供了比较成熟的方案,比如基于LSM Tree的RocksDB,可以直接引入代码依赖把一个数据库嵌入到服务里。
如果一个多实例的服务采用了该模式,每个实例都会有自己的一份缓存,缓存互相独立,都有自己的数据。
从某个角度来看,缓存是原始数据在过去某个时间点的快照。如果数据不是静态的,不同服务实例的内存里将存储不同版本的快照。结果就是:不同实例上执行同一个查询可能获取不同的结果,具体如下图所示:
图1: 服务不同实例各自有自己的内存缓存
使用共享缓存,可以解决私有缓存不一致的问题,它能保证不同的实例看到同一份缓存数据。缓存作为一个服务与业务服务独立部署,如下图所示:
图2: 使用共享缓存
共享缓存提供了很好的可扩展性。缓存服务通常部署在一个服务器集群上,通过hash算法把数据均匀地分散到集群的各个服务器节点上。这些逻辑对于业务服务是透明的。通常业务服务的实例只需要把请求发送给缓存服务,缓存服务决定数据存放在哪些节点上,或从哪些节点上读取数据。缓存服务扩容对业务服务也是透明的,所以操作起来简单很多。
共享缓存最常见的是Redis/Memcached等KV缓存,有一个例外是,Google的groupcache,它直接使用业务的服务器节点内存,看起来像是私有缓存,但通过服务发现,可以使用集群里所有实例的内存,实现一个分布式缓存。
值得注意的是,缓存服务扩容时,读写性能可能有一定程度的下降,可根据业务负载判断是否需要在低峰期进行。
共享缓存有两个缺点:
缓存访问更慢:从缓存服务读取数据要走网络,加上缓存服务内部的路由逻辑,比内存缓存慢;
独立的缓存服务增加了技术方案的复杂性;
在合适的场景下,缓存能够极大地提升服务的性能、扩展性和可用性。一般情况下,数据量越大,访问这份数据的用户量越大,缓存的效果越好。在应对大流量的并发请求时,访问缓存比访问数据源,能够显著降低服务延迟,并支持更高的QPS。
常规的数据库(比如MySQL)可能只支持一定数量的并发连接。然而,如果从一个共享缓存读取数据,而不是从底层的数据,即便数据库的并发连接已经被消耗完,client仍然后沟正常获取数据。在数据更新频率不敏感的业务场景下,即便数据库服务挂了,client也能继续使用缓存里的数据。
我们推荐把高频读且低频更新的数据缓存下来,不推荐缓存敏感数据(比如权限验证信息)。
使用时,对于业务上绝对不能丢的数据,务必持久化到数据库。即便缓存服务挂了,服务仍然能够直接操作数据库,而不至于丢失一部分数据。
为了保证缓存的有效性,关键点在于确定1)缓存什么数据、2)什么时候做缓存。
我们可以在第一次读取数据时把数据添加到缓存,这样服务只需要从数据库读取一次,后续的读请求均走缓存即可。
我们也可以提前把部分/全部数据加载到缓存,比较常见的是在服务启动阶段。不过,在大型系统中,我们不推荐这种方式,因为这可能导致数据库的访问量突增,导致服务不稳定。
所以选择哪一种呢?这时候我们需要对流量进行一些分析,协助我们判断是否对缓存进行预热,缓存什么数据。比如,一些用户每天都会使用应用程序,我们就可以把这些用户的静态数据缓存下来;但对于一周访问一次系统的用户,缓存就没必要了。
缓存尤其适用于不可变数据或变化频次很低的数据。常见的有电商场景下的商品信息、商品价格等,生成比较耗时的共享静态资源。在服务启动阶段,我们可以预加载一部分数据到缓存中,用来满足频繁的资源请求,提升系统性能。为了保证数据更新,可以启动一个后台进行,定期从数据库拉取最新的数据,更新缓存。还有一种比较复杂的方案,通过消息队列监听数据的变化,更新缓存。
对于动态变化的数据,缓存的效果相对比较有限,在一些特殊场景下除外(后面会详细说)。原始数据定期发生变化时,要么缓存中的数据很快就过期了,要么数据同步的代价降低缓存的效用。
我们不一定要缓存实体的所有信息,有些场景下只需要缓存特定的不可变字段,有时候只需要缓存过滤条件以方便获取ID。举个例子,一条数据代表一个有很多字段的对象,比如一个银行客户(字段有名字、地址、账户余额等),ta的名字地址通常是静态的,而账户余额则经常发生变化。这种情况下,可以只缓存这些静态字段,其余字段在需要时从数据库或其他服务获取即可。
预热缓存还是按需加载,还是全都要,我们更推荐让数据说话,使用的手段有性能测试、使用情况分析等。最终决定应考虑到数据的变化情况和使用情况。如果服务需要承载大量的请求,或是高度分布式的,缓存使用率和性能分析也十分有必要。比如在高并发的场景下,缓存预热可以降低高峰期数据库的压力。
缓存也可以用来避免重复的计算。如果一个服务调用需要处理大量数据,或者进行复杂的计算,我们可以将结果缓存起来。如果后续出现同样的计算,服务直接读缓存即可。
服务可以修改缓存里的数据,但存在一些副作用。我们不推荐把缓存作为一个持久存储使用,而是预设缓存里的数据随时可能丢失。千万不要把有价值的数据只放在缓存里,在数据库里务必也存储一份。一旦缓存失效或缓存服务挂了,我们也不会丢失数据。
如果你频繁修改数据库里的数据,数据库的压力会比较大。举个例子,如果一个设备频繁地报告自己的状态和数据指标,如果应用层考虑到缓存会经常过期,选择不进行缓存;直接从数据库读写也会存在同样的问题,相当于把压力转移到了数据库上。
这种情况下,可以考虑把动态数据直接存储在缓存里,而不是放到数据库。考虑到这是非核心数据,也不需要进行审计,有一些变更没有被记录到也可以接收。
大多数场景下,缓存里的数据是从数据库拷贝过来的,几乎一模一样。但数据缓存以后,数据库里的数据可能发生变化,导致缓存里的数据过期。很多缓存服务可以配置过期时间,以避免数据过期的时间太久。
数据过期后,缓存会把数据清理掉,下一次请求来的时候,服务必须从数据库重新获取数据(然后添加到缓存里)。我们可以给缓存服务设置一个统一的过期时间,也可以针对每一个key设置独立的过期时间。
有时候,缓存会被占满。这种情况下,把新数据写入缓存会导致已有的数据被删掉,这个过程叫缓存逐出。最常见的缓存逐出策略是LRU,当然也可以把逐出策略设置成不逐出,会导致新数据写入失败。最常见的Redis就提供了:
noeviction: 不逐出、
lru:最少使用算法,最长时间没有使用的数据
random: 随机逐出数据
lfu: 一段时间内使用频次最低的数据
ttl: 到了过期时间的数据,与是否访问无关
通常情况下,一个服务的多个实例共享一个缓存,每个实例都会读/写缓存里大的数据,产生了并发读写问题。考虑一个场景,应用程序需要更新缓存里的一条数据,但我们必须保证一个实例写入的数据不能被另一个实例的写覆盖掉。
考虑到可能的数据竞争,有两种更新策略:
乐观锁:再更新数据的前一个瞬间,检查缓存里的数据在上次读之后是否发生过变化。如果没有变化,这次更新就是有效的。相反,应用程序需要根据业务逻辑判断是否执行此次更新。这个方法适用于数据变更不频繁或冲突不经常发生的场景;
悲观锁:获取一个条数据时,给数据加锁,避免其他实例做变更。加锁确保了冲突不会发生,但会阻塞其他实例处理这条数据。悲观锁会影响技术方案的扩展性,比较适合耗时很短的操作。这种方法适用于冲突经常发生的场景,尤其是大量的数据被更新,必须保证变更的一致性;
在生产环境中,为了保证核心业务的稳定性,我们通常会使用读写分离的数据库方案。最常见的莫过于MySQL主从结构,比如一主二从的结构。上层应用写入/变更数据时,通常会访问主节点,读取数据时访问从节点。主从的数据同步借助于binlog机制,靠的是最终一致性。
一般情况下,这没什么问题。但涉及到短期大量的数据写入时,binlog同步会出现明显的延迟。设想一下,在应用程序和MySQL之间如果还有一层缓存,应用程序的一个实例 1)更新数据库; 2)将缓存置为过期;此时应用程序的另一个实例读取这条数据,触发一次cache miss,所以它MySQL从节点读取最新数据;但此时binlog同步还没有完成,所以读到了旧数据,记录在缓存中。我们做一个大胆的假设:
这条数据数据此后很长一段时间没有被更新过;
缓存没有设置过期时间,或过期时间很长;
那么后面很长一段时间,应用程序读到的都是旧数据,与实际不匹配。有什么解法呢?
最简单粗暴的解法是:读写都走主节点,这相当于把从节点给干废了,来一次单点故障,整个服务就挂了;
比较折中的解法有:
给缓存设置一个不长不短的过期时间,保证数据库压力不大,缓存也有效果;
只缓存不变化的字段,变化的字段从数据库取;
如果把最终一致性贯彻到底,可以做一个消费binlog写缓存的常驻任务,不过不建议自己写,最好复用公司的大数据体系(binlog2kafka,Flink SQL)。