0%

线上Cache系统设计

缓存一直是线上系统不可或缺的部分,本文总结了缓存系统中需要关注的一些问题

缓存系统的挑战

  • 缓存击穿 Cache Breakdown: 热点数据突然过期
    • Data sharing
    • Async Refresh / Pre Fetch
    • Request aggregation
  • 缓存雪崩 Cache Avalanche: 所有的缓存数据同时失效
  • 缓存穿透 Cache Penetration: 查询不存在的数据
    • Content filter
    • Error cacheing
  • 内存管理
    • 堆外内存 vs 堆内内存
  • 淘汰策略
    • Memory Limit
    • Time Limit
  • 数据管理
    • Marshal vs Raw Object (marshal cost vs GC cost)
    • Lock vs Bucket Lock vs Object Lock

Data sharing

通过网络或其它中间件在不同节点间共享缓存数据,可以在单node数据过期时快速通过其他拥有更新数据的节点快速恢复,避免回源降低延迟

Async Refresh / Pre Fetch

通过给数据设置软过期时间,在真正过期之前就异步刷新数据,避免用户等待回源。

同时通过提前刷新,在源服务报错时留出重试和恢复的时间

Request aggregation

在多个client请求相同的需要回源的数据时,只进行一次回源,避免多次请求

Content filter

通过布隆过滤器等手段,阻止对不存在资源的请求,避免大量回源压垮服务

Error cacheing

在回源报错的情况下,适当缓存源错误并返回给用户,避免错误情况下持续回源压垮后端服务。

目前开源cache组件支持error cache的很少,但实际上这是一个非常实用的服务,当缓存中数据不存在或过期的请求,如果回源报错(4XX,5XX),可以短暂缓存源报错,缓存时间可以是正常缓存时间的十分之一或更短,能够有效避免对源服务的恶意攻击。

堆外内存 vs 堆内内存

常见的缓存服务的内存申请基本上可以分为堆内和堆外两大方向,对于有GC语言来说,堆外内存可以减少GC消耗,代价就是cache需要自己管理内存。
而堆内则有实现简单可靠的有点,并且可以配合对象缓存进一步避免序列化开销。

堆外内存的另一个缺点是无法按需分配,往往需要提前分配几百M到几G的内存空间,并且难以缩扩容,会造成一定的资源浪费。

Marshal vs Raw Object

对于缓存数据的存储,同样存在两大方向,分别是序列化后存储和直接缓存数据对象。

通常使用堆外缓存方案的cache需要使用序列化方案,一方面堆外内存不容易直接映射为语言呢的对象,另一方面序列化后只需要管理字节数组,内存管理的难度大幅下降。

而使用堆内内存的cache系统则可以直接缓存对象,可以避免序列化开销(有时序列化开销占比会非常大),如果cache对象不需要修改,甚至可以避免拷贝开销,同时节省内存和cpu

Lock vs Bucket Lock vs Object Lock

通常的cache系统可以抽象为一个map,同map一样会面临并发读写的难题

而解决方案也可以照搬map的Bucket Lock 或 Object Lock。

单节点缓存系统的工作流程

如上图展示的,在一个缓存系统中,cache可能出现的所有状态
大体上,cache状态可以分为三大部分,对应超时数据,异常数据和正常数据
当cache刚建立时,会从init状态迁移到数据超时状态,此时数据需要在阻塞模式下同步更新,更新期间所有发来的请求都会被阻塞

同步更新状态的转移有2种可能,

  1. 同步更新中如果updatefn返回error,则cache状态会转移到异常重试冷却状态,在这个状态下,cache仍然会缓存之前updatefn返回的error response,并通过用户请求触发异步更新,更新的最小间隔是200ms(或者其他用户设定的间隔),如果更新失败,则缓存最新的error response,如果成功则迁移到正常数据的状态流中。

此外,由于更新是通过用户请求触发的,如果在得到异常数据后很久没有没有请求,还会再次回到同步更新状态。

  1. 在得到正常数据后,cache在SoftLime时间内只返回数据而不进行更新,当缓存时间超过SoftLime而不超过HardLimt时,cache在用户请求时会触发异步更新,而如果超过了HardLimt则会进行同步更新。在缓存了正常数据的状态下,如果updatefn返回了error,cache会继续保存之前的成功的返回值而不会保存失败返回,同时也不会更新update time,在下一次用户请求时会再次触发异步更新,但是如果update持续失败,缓存时间超过了HardLim回到同步更新状态,此时更新失败会导致回到异常数据状态

多节点共享数据的缓存更新流程(通过redis共享数据)

Shared object cache 通过redis在cache之间共享数据,可以在单node数据过期时快速通过其他拥有更新数据的节点快速恢复,避免回源降低延迟,作为一个可选的中间件,引入data sharing后的更新流程比起单机版的cache会更复杂一点,

为了避免引入redis导致cache data的ttl混乱,redis中除了数据外,还记录了updatetime和编码类型

在真正执行用户的update function之前,会先检查reids中是否有可用数据

redis中,每个cache item 的 HASHS Struct 如下:

1
2
3
4
5
{
data: <data>
marshal: <marshalType>
update: <updateAtMs>
}

为了保证缓存更新的原子性,在更新数据时通过如下lua比较尝试写入的数据是否新于已有数据,并返回最新数据的时间戳,通过返回的时间戳,调用方也可以知道自己写入是否成功

1
2
3
4
5
6
7
8
9
10
11
local expirePTTL = redis.call('PTTL', KEYS[1])
if (expirePTTL >= 0) then
local serverTime = redis.call('TIME')
local expireTime = tonumber(serverTime[1]) * 1000 + tonumber(serverTime[2] / 1000) + expirePTTL
if (expireTime > tonumber(ARGV[1])) then
return expireTime
end
end
redis.call('HSET', KEYS[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7])
redis.call('PEXPIREAT', KEYS[1], ARGV[1])
return ARGV[1]

为了准确控制过期时间没有使用传统的TTL设置生存周期,而是使用用PEXPIREAT设置毫秒即的过期时间戳,这样即使指令的发送和执行上有一些延迟,最终的数据TTL还是严格和cache instance 一致的
并且在update时,会检查当前数据的过期时间戳和已经存在的数据的过期时间戳谁更大,只有最新的数据才能留存在redis中。
这里有一个限制,在redis7之前,设置超时时间戳可以使用PEXPIREAT,但是设置后就无法再次获取时间戳了,只能使用PTTL获取剩余的毫秒级TTL,在redis7之后新增了PEXPIRETIME指令可以获取原始的时间戳。

Steam Cache

出了常见的object cache,对于流数据的缓存也是比较常见的一个场景

Stream cache对外的表现是一个滑动窗,在第一次缓存建立后,会主动向更新和更老的feed,尽享填满自己缓存窗口从而满足用户请求