Redis Client Side Cache - Redis客户端缓存 - RedisConf18

Posted by LB on Sun, Mar 15, 2020

一. 背景描述

客户端缓存是一个有意思的话题,它不是空穴来风的技术,在最新的Redis RC版本已经正式开始着手CSC方案的设计,虽然目前版本的CSC还不能真正的商用,但是市面上也有一些其他公司开始着手试探CSC相关方案的设计与实现。

目标比较有名的模型是两种:

  1. Ben Malec paylocity公司方案
  2. Redis6 RC方案

这两种方案并不是独立的,他们各有各的优势,paylocity公司的方案被redis团队所赞赏,并吸收了一些思路进入Redis RC版本中,Redis RC版本主要是提供了一些server端的协助,但是本质上还是没有完整的CSC方案。

二. RedisConf2018大会 Ben Malec分享

这里,我们将阐述RedisConf 2018年的经典分享,这个分享围绕CSC机制的相关设计与实现,并且方案已经被广泛使用在paylocity公司,有很高的的借鉴意义。

Ben Malec的分享主要围绕如何实现一个和Redis缓存同步的本地内存缓存。

首先,我们看一下简单的网站模型,模型图如下:

接着,Ben提出很重要的缓存象限,缓存象限图如下所示:

缓存最好的应用场景就是针对更改少、请求频繁的数据读写场景。

客户端缓存,首先需要面对的问题就是 “缓存数据滞后

这部分演讲,Ben发散思维了所有Web服务器尝使用的“文件系统观察”功能。

随后,客户端缓存会出现“跨服务器缓存数据不一致”问题。

这种问题并不是只会在不同的机器间出现,还会在同一台机器不同进程中出现。

比如在两台机器针对缓存都设置了相同的TTL生命期,但是由于机器间时间可能不同步,从而造成缓存不一致情况。更坏的情况就是,数据已经更新了,但是客户端缓存没办法及时更新,造成用户请求到旧的数据,如果再多台机器负载的情况下,极有可能出现一会新值、一会旧值得问题,这种飘忽不定的缓存返回会造成用户较差的使用体验。

接下来,Ben提出一个很重要的时间观点,服务器间想在大约相同的时间内更新相关的key,这个大约相同的时间证明这个缓存方案并不一定能够满足分布式强一致,只是在合理的时间范围内数据一致。

接下来,Ben提出第三个缓存问题,“缓存踩踏”问题

这里所说的就是如果自己完全制作一个进程内缓存,有很多需要考虑,比如启动数据加载,数据池的备份,服务器扩容过程,等等问题。

Redis可以提供简单的缓存解决方案。

Redis缓存可以很好地解决缓存一致性问题,也可以解决缓存数据滞后问题,也不会有数据践踏。

但是redis也有一些其他问题,比如每次缓存获取都需要tcp往返通信,虽然redis已经很快了,但是本地内存的访问速度仍然比网络io速度高太多。

这里,Ben提出如果在redis基础上,再增加进程内缓存,效果就会更好了。

针对这种本地缓存方案,首先提出了三个需要做的事情:

  1. 解决数据一致性问题
  2. 解决数据滞后问题,主要围绕进程内缓存和远程redis之间的滞后问题
  3. 不要让网络爆炸,要控制合理的网络通信

借助redis,我们是不是可以更好的实现这个功能呢?

上面这部分讲述了一个问题,如果我们想让机器间的数据保证一致性,如果仅仅通过广播变更的key-value,这将是致命的,因为大量的key-value将引爆网络,还有一个原因就是你广播了key-value数据,并不是所有的节点以后都会使用,这就会造成效率问题,这些问题几乎都是围绕网络,但是还没考虑网络的质量问题,比如网络质量很差的情况下,节点可能收到多组不同的改动,这些改动可能会数据践踏,但是你不知道践踏的顺序,从而造成数据的不一致问题。

因此,我们并不是广播key-value,而是只广播key,但是你也知道redis支持key数据,最大可以达到512MB,就算不是512MB,就算是1kb的数据,我们的网络就能抗住吗,所以简单的广播key是不理智的。

redis集群中采用hash槽位来进行数据分片,那么我们是否可以借鉴这种思路呢?我们不再广播key,而是广播key所计算的hash值,这样如果key的数据多么大,我们都能控制在网络上传输的数据大小。

我们放弃了广播key,而选择同步16bit的key hash槽数据,这样操作的优势明显,首先广播数据的大小被控制了,并且解决了数据一致性问题,我们只是广播hash,并没有广播数据,当某个hash出现了脏数据,它将会在下次访问时被感知并被更新。这个也有一点缺陷需要注意,因为我们借助了hash槽位,所以一个hash slot上会包含很多key,这些key中的一个被更新,则这组hash slot都将失效。

计算遍历所有的 key 吗?命中脏 slots 的话,就删除这个key?但是这样的话相当于对每一个缓存更新操作,客户端都要遍历计算一遍自己所有 key 的 slot,显然是不可接受的。

这里也是采用惰性计算的思想:客户端收到了 slot 更新的广播,只把 slot 存起来,当真正用到在此 slot 中的 key 的时候才去 Redis 更新。那么就会有这样一种情况,slot 中部分 key 更新了,部分 key 没有更新,如何区分开哪些 key 已经在 slot 更新之后更新过了呢?这里只要记一下 slot 更新的 timestamp 就可以,每一个 key-value 也带有一个 timestamp 属性。如果 key 的 timestamp 早于 slot 的 timestamp,那 key 就是需要更新的;更新之后 key 的 timestamp 就晚于 slot 的 timestamp 了。下次可以直接用。

上面所述的hash slot范围影响问题,并不是不可以缓解的,redis6中提议采用更长的hash算法,支持千万个slot,从而降低数据碰撞率,从而环节hash slot更新影响问题。

针对hash slot部分,我们需要在客户端内维护一个lastUpdated数组,这个数组用来记录hash slot的更新时间,这里,需要特别强调一点,这个hash slot时间戳,一定是客户端本机时间戳

上图是一个关键的数据结构图,这个lastUpdated 存放着所有hash槽位最后更新的本地时间,通过提前预分配空间,可以降低查询索引时间复杂度到o(1)。右面的是客户端内的缓存结构,每一个key都对应着一个数据结构,数据体结构中包括hash槽位最后写入客户端缓存的时间,还有数据体

上图展示的是客户端缓存的读取操作流程,从流程中可以看出:

  • 如果已经检测到客户端进程内有所对应的缓存,则读取最后一次更新的时间戳,如果时间戳已经过期,则进行远程Redis的读取和本地更新操作,如果时间戳没有过期,则返回缓存给客户请求。
  • 如果客户端进程内没有所对应的缓存数据,则读取远程redis的缓存数据,计算key所对应的hash槽数据,把缓存写入到进程内,并返回缓存响应给客户端。

增加一个数据进入缓存的主要流程包括:

  1. 获取当前时间戳
  2. 计算key所对应的哈希槽位数据
  3. 写入数据到redis服务器
  4. 写入缓存数据、时间戳信息到客户端缓存内部
  5. 发布update信号到所有的client,发布的信息不是每个key,而是hash

虽然从上面的读写操作看来,似乎流程是没问题的,但是还有一些挑战性的计时问题,这个问题将是最大的问题,任何一个系统都不想访问已经更新过数据的旧数据。我们使用同步消息更新lastUpdated数组,但是这种更新可能随时都会存在。

特别是在高频请求,或者hash slot数量不多、但是存储的缓存量很大,会出现频繁的数据更新信号、数据读写过程冲突问题,这个问题是很头疼的问题,我们该如何解决这种冲突?

解决上面的这个问题,我们当然可以使用分布式方案进行优化、比如redis分布式锁。

我们总是关注并发和一致性之间的问题解决。采用分布式锁解决一致性问题看似非常合适,但是基本丧失了并发性。因为每个并发单元均需要等待锁的释放。

我们目前没有采用分布式锁、master时钟的方案,而是进行了操作的顺序化设计,非常仔细的设计了操作顺序。

这部分非常关键,有很多细节需要注意,主要包括以下几点

  1. 所有的 timestamp 都是 server本地时间戳:获得了 slot 更新的消息,将 slot 的更新时间设定为当前 local timestamp;进程更新 key 了,进程将 key 的更新时间设定为 local timestamp。实际上,timestamp 已经当成一个相对于本地 server 的偏移量来用了,无论是不同进程之间的时间如何偏移不准,都没有影响。
  2. 必须先更新 Redis,Redis 更新完成之后再广播更新消息(再次强调,Redis 作为 Source of the Truth)。
  3. 在更新 Redis、in-process cache 之前就获取 timestamp,这一步很关键。这里解释一下为什么要先获取 current timestamp 再进行更新:其根本目的是 slot key 的 timestamp 就尽量提前。如果在 Get current timestamp 之前收到了 slot update message,那么我们的更新操作一定发生在其他进程的更新操作之后,没有毛病;如果在 Get current timestamp 之后收到了 slot update message,那么不管如何,我们的 key timestamp 会落后收到的 slot timestamp,会去 redis 获取,也没有毛病。假设这里先更新完再获取 timestamp,会有这么一种情况:我们更新好了 in-process cache,这时候来了一条 slot update message,我们更新了这个 slot 的 timestamp,然后我们自己的更新操作到了获取 timestamp 这一步,我们记录了自己的 key timestamp 和 slot timestamp。就造成了我们的 key 更新时间实际上晚于真正的 key 更新时间,我们保存了一个过时的 key 却不知道。

上图很重要,展示了一个更新顺序:

  1. #1第一步更新远程redis缓存
  2. #1第二部发布推送、通知其他机器数据更新
  3. #2到远程redis去取数据,并把最新的redis缓存数据存储在自己的进程内。

这还有一个边界情况,这是一个计算机时间戳的边界条件。我们正在使用时间戳,它的分辨率是300纳秒,这也就意味着冲突可能发生在300纳秒间,在这个时间段内,一方面我们接收到了更新信号,一方面我们需要读写数据,在这个最小计算机时间周期内,他们可以被认为是统一时间发生的。

这个情况就是在一个 timestamp 分辨率下,客户端更新缓存,但同时收到了 slot 更新的消息。即客户端 key 的 timestamp 是 a,但是在 a 这个 timestamp 的同时其他进程更新了缓存,这个时候 timestamp a 依然是正确的,但其实缓存住的是一个过时的 key。

其实这个发生的概率太小了,timestamp 的精度是 300ns 的话,必须在 300ns 内更新完 redis 缓存和 in-process 缓存,收到 sync 消息,才有可能发生——但是依然有概率发生的。

这里Ben提到了一个很简单的解决方案,但是我被这种简单的方案背后的智慧所震撼,如果时间戳更新的频率是300纳秒,那么我们只需要针对开始操作的时间戳进行 减1操作。

获取时间戳 之后之后总是 -1(其实不必-1,减一个 timestamp 精度就可以)。这样就保证收到同步消息时,客户端总是倾向于缓存脏了、去 redis 获取。如果发生一个 时间戳精度内出现缓存更新和收到同步更新消息,那么客户端实际缓存更新的时间肯定晚于其他进程更新缓存的时间,因为本地客户端把时间调快了,所以客户端保存的 key 是新的。

Ben针对自己的客户端缓存方案,还提了几点优化方案:

  1. 早期测试显示Redis的命中率比预期高

    • 根本原因是客户端正在处理更新发布/订阅他们发布的消息,因为他们正在处理自己刚刚发布到redis的缓存数据。
    • 解决方案是让发布/订阅处理程序忽略以下消息:源自相同的缓存提供程序实例
  2. 更新Redis包括执行两个Redis命令,一个更新值,第二个通知其他客户端数据变化。

    • 但是我们不想发生两次TCP往返。
    • Lua脚本可以解决!

针对第一个问题,这是个因为订阅广播而造成的重复处理问题,这个问题可以通过在广播阶段增加一个client id,这个client id可以是一个ip、mac地址,或者一个配置好的唯一ID,从而避免重复处理。

针对第二个问题,更新远程redis需要一次tcp交互、广播通知客户端也需要一次tcp交互,因此可以借助lua进行统一操作,由lua进行远程redis的更新和广播通知。

三. 问题阶段

3.1 客户端缓存能够快多少?

根据具体的硬件可能会不同,但是大概会在40 - 45 倍的性能。

3.2 当众多客户端中某一个客户端没有收到订阅消息,如何处理?

首先,这种情况很少见,针对这种情况,我们可以针对数据set进行一个ttl设置。

针对这点,我有一点想法,客户端缓存的场景主要是解决远程redis的效率或者网路io问题,这个并不是万能钥匙,因此hash slot不能太大,合适非范围可以让客户端缓存的订阅依赖性下降,让客户端缓存既分担远程redis的压力,也能提高缓存性能。

相关材料:

  1. https://www.youtube.com/watch?v=kliQLwSikO4
  2. https://www.slideshare.net/RedisLabs/redisconf18-techniques-for-synchronizing-inmemory-caches-with-redis
  3. https://www.kawabangga.com/posts/3590