redis客户端缓存-热key杀手锏

1,744 阅读12分钟

先赞后看,年薪百万~~~ 官方文档翻译总结,如有错误请指正

通常应用服务(application)需要数据时都从redis(database)服务中去获取:

+-------------+                                +----------+
|             | ------- GET user:1234 -------> |          |
| Application |                                | Database |
|             | <---- username = Alice ------- |          |
+-------------+                                +----------+

当使用客户端缓存后,应用程序会把热查询的结果直接放到应用程序的本地内存中,之后再用到这些查询时直接从本地内存取,无需再次访问redis服务:

+-------------+                                +----------+
|             |                                |          |
| Application |       ( No chat needed )       | Database |
|             |                                |          |
+-------------+                                +----------+
| Local cache |
|             |
| user:1234 = |
| username    |
| Alice       |
+-------------+

热key效果非常明显,大大降低redis服务端压力

客户端缓存优点:

  1. 延迟非常小,省去了访问redis服务的网络开销
  2. redis服务收到的查询数要变小很多,这样redis只需要很少的节点就能达到相同的并发数(在保证application相同QPS/TPS的情况下,使用客户端缓存时需要的redis节点更少)

两大难题

第一个问题是如何使已过时的客户端缓存失效

有时候这个问题或许不是问题,有些场景下只需要设置客户端缓存的最大存活时间(TTL)即可达到目的。更复杂的场景中也可以利用redis pub/sub来通知过时key过期。但是pub/sub成本比较高,通常每次更新缓存操作都需要使用PUBLISH把过时key推送到队列中,通常会浪费redis-server更多的cpu时间片,然后各个应用客户端都需要订阅该队列,即便没有缓存该key的客户端也要收到redis-server推送过来的过时key

ps:官网上标题写的是两个大问题,我也就只看到了这一个问题,另外一个问题也没说是啥 (⊙o⊙)…

redis客户端缓存实现

redis通过 Tracking 来实现客户端缓存失效通知及要通知哪些客户端,它有两个模式:

  1. 默认模式,redis-server记住每个客户端访问的key,当key被修改失效后,redis会通知对应的客户端。 有一定的空间成本(记住每个客户端缓存的key需要消耗内存),但是仅仅通知缓存有对应key的客户端
Client 1 -> Server: CLIENT TRACKING ON
Client 1 -> Server: GET foo
(The server remembers that Client 1 may have the key "foo" cached)
(Client 1 may remember the value of "foo" inside its local memory)
Client 2 -> Server: SET foo SomeOtherValue
Server -> Client 1: INVALIDATE "foo"
  • 客户端可以启动 tracking , 连接开始时没有启动tracking
  • 当tracking启用,一直到连接关闭redis-server会记录每个客户端请过的key(发生读操作的key)
  • 当一个key被某些客户端修改过后,或者过期,或者超过内存限制被淘汰,所有启用tracking且请求过该key的 客户端会收到失效key消息(invalidation message)
  • 当客户端收到消息后会删除相应的key,防止过时的数据被访问到

表面上看很完美,但是仔细想一下假如有10k个客户端,在连接存活期间,服务端需要存储多少信息?所以redis通过以下几点限制了内存使用和cpu使用

  • 无效列表Invalidation Table) 用来记录每个key对应可能持有该key的客户端列表,当该表中的key被修改后读取该key对用的客户端列表并通知各个客户端,并从此表中删除该key。该表是有容量大小限制的,当表已经放满后,又来了一个key需要记录时,会从此表中淘汰一个最旧的key把新key放进来,淘汰旧key时会也会强制通知各个客户端失效该key(即便该key现在并没有被修改),为的是:之后该key被修改找不到对应客户端去通知从而引发生过时数据被访问问题
  • 在无效列表中存放的是每个客户端的唯一数字ID,如果客户端连接断开,信息会被渐进的GC
  • 无效列表是所有database共享的,没有database numbers来区分,所以当database2中的一个fookey被修改时,缓存了database3中fookey的客户端也会被通知失效。为的是减少内存使用,减少实现的复杂度
  1. 广播模式 ,通常不会去记每个客户端缓存的key,因此此方法在redis-server不会浪费任何内存。 取而代之的是,客户端会订阅一些前缀的key(object: 或者 user:),并且当key的前缀能匹配上时每次都会收到通知

双连接 模式-用于失效信息通知

Redis 6 新协议连接RESP3,在同一个连接中既能做数据查询又能用来接收失效key的通知信息。但是许多客户端实现中更倾向于用两个独立的连接:一个做数据查询,一个用来接收redis-server的失效信息。之所以这么做是因为当一个客户端启用tracking后他可以通过客户端id指定另外一个连接用来专门接收失效key信息,当有失效信息时会重定向到专门的连接,同一个客户端的所有数据连接都可以重定向到这个专门的连接上,当客户端实现了连接池时这种方式非常有效。这种双连接模式也适用于RESP2,RESP2也仅支持这种模式,因为它的连接不像RESP3一样能复用

一个完整的例子,RRESP2的实现的完整过程:启用trackinng功能重定向到一个专门的失效信息连接,查询一个key,然后当这个key被修改时收到该key失效的通知信息

开始的时候客户端打开第一个连接Connection 1作为专门的失效信息连接,拿到连接id然后通过pub/sub订阅一个特殊的频道:

(Connection 1 -- 专门接收失效信息的连接)
CLIENT ID
:4
SUBSCRIBE __redis__:invalidate
*3
$9
subscribe
$20
__redis__:invalidate
:1

现在,数据连接2开始启用tracking功能:

(Connection 2 -- 数据连接)
CLIENT TRACKING on REDIRECT 4
+OK

GET foo
$3
bar

连接2可能在本地内存中缓存了 foo -> bar

其他客户端的一个连接修改fookey的值:

(其他客户端的连接)
SET foo bar
+OK

紧接着,失效信息连接将会受到要失效的key foo

(Connection 1 -- 专门接收失效信息的连接)
*3
$7
message
$20
__redis__:invalidate
*1
$3
foo

客户端收到key后会检查自己本地内存是否有该key并使之淘汰

注意: Pub/Sub传输的信息是一个数组,一个key作为一个元素。为的是当有一批key过期时也能通过一个消息来传输

还有一点需要注意,RESP2使用pub/sub纯粹是一个技巧,是为了重用旧客户端实现。但实际上并不是所有的客户端都会接收失效key的消息,只有是使用REDIRECT指定的连接才会真正的使用pub/sub来接收消息

当使用RESP3时,无效消息将在同一连接中推送,或者重定向到一个专门的连接中推送(详见RESP3规范)

tracking应该追踪什么

如你所见,默认情况下,客户端是不需要告诉服务端自己缓存了哪些key的。客户端查询过的每个key都会被服务端追踪记录,因为这些key有可能会被客户端缓存

优点很明显:不需要客户端明确告知缓存的key。但是只要服务端有写流量,就必定触发对应的客户端失效。服务器显然是以客户端必定会缓存访问到key为前提的,所以:

  1. 当客户端倾向于使用淘汰旧key的策略来缓存时,这样做会更有效率
  2. 服务器将被迫保留有关客户端key的更多数据
  3. 客户端将收到有关其未缓存的对象的无用的无效消息

指定客户端要缓存的key

开启tracking时指定OPTIN选项

CLIENT TRACKING on REDIRECT 1234 OPTIN

这次模式下,客户端默认不会缓存任何key,除非发送了CLIENT CACHING YES命令,会缓存此命令的下一条命令:

CLIENT CACHING YES
+OK
GET foo
"bar"

下一个命令如果是事务MULTI则会缓存里面所有的命令,如果是lua脚本也一样。这样可以减少CLIENT CACHING YES命令的次数,算是一个优化

广播模式

到目前为止都是在讲客户端缓存的第一种模式(默认模式:redis-server追踪每个客户端可能缓存的key,当key失效时,通知这些客户端),现在开始说第二个模式。此模式不会消耗redis-server任何内存(也消耗啊,官方的话自相矛盾,下面第二条不就消耗内存么,只不过前缀少时消耗的内存比较少而已),但是却会向客户端发送更多的无用消息,这种模式的主要行为有:

  • CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user: 启用tracking时 使用BCAST选项加多个前缀开启广播模式
  • redis-server在无效列表中不存储任何数据,而是使用 key前缀(user:)和前缀对应的客户端列表 的数据结构来记录每个前缀对应的客户端
                  +--------+
                  | client |
                  +--------+
+-------+         +--------+
| user: |  +----> | client |
+-------+         +--------+
                  +--------+
                  | client |
                  +--------+
  • 当有key被修改后,能匹配上此key的所有前缀的所有客户端列表都会收到通知
  • 当redis-server注册的前缀越多,cpu消耗越严重。但是前缀越多,客户端匹配或许能越精准,客户端收到的无用通知就越少
  • 当一个key被若干或单个前缀匹配上时,只会创建一个通知,然后把这一个通知发送给对应前缀的所有客户端。只创建一个通知可以节省cpu消耗

NOLOOP选项

一个redis-cli已经缓存了keyfoo想要修改该key,直接修改redis-server中的keyfoo,然后redis-server再通知此客户端失效foo。这种情况下不会有问题。但是假如redis-cli修改了redis-server中key的同时修改了本地内存中的值,redis-server又来通知它失效foo是不是就有点多此一举了?

所以tracking提供了一个NOLOOP选项,可以在默认模式和广播模式中使用。当开启此选项后,redis-server不会通知修改key的客户端失效key

竞争条件-双连接模式的不安全问题

D表示数据连接 I表示失效通知连接

不安全场景:

[D] client -> server: GET foo
[I] server -> client: Invalidate foo (somebody else touched it)
[D] server -> client: "bar" (the reply of "GET foo")

D连接先发送了一个命令GET foo读取foo,在未收到GET foo的响应之前(其实是redis已经发出了响应,响应正在回来的路上),redis-server的foo被其他客户端修改了值,和D是同一个客户端的I连接收到了失效foo的消息通知(并进行了对应通知的处理),之后D连接的GET foo的响应才回来,显然是回来了一个过时的数据,因为redis是在foo被其他客户端更新之前响应的GET foo

修改过后的过程:

客户端缓存: set `foo`  `caching-in-progress`
[D] client-> server: GET foo.
[I] server -> client: Invalidate foo (somebody else touched it)
客户端缓存: 从本地内存中删除`foo`
[D] server -> client: "bar" (the reply of "GET foo")
客户端缓存: 发现没有`foo`不进行set `bar`

核心是 允许在本地内存中set key的条件是:

  • 该key已存在,此时是更新操作。允许
  • 该key不存在,有可能已被失效,还有可能是从来没缓存过该key,此时只允许 set key caching-in-progress

当使用RESP3的单链接模式时即一个连接即用来数据传输又用来接收无效通知,此时是不存在这种不安全问题的,因为单链接场景中,所有的数据和通知都是按顺序的(按顺序就意味着会阻塞,这也是单连接的一个问题吧?)

意外断开连接会发生什么问题吗

当仅有的一个失效通知连接断开了,那对应客户端的本地缓存岂不是没法实时通知更新了?以下行为可以避免此问题:

  1. 当失效通知连接断开后,客户端会强制清空本地所有缓存
  2. 当使用 RESP2+Pub/Sub或者RESP3时,会定期ping无效频道(应该是专门用来健康检查的频道吧,另外连接是pub/sub模式时也可以发送ping),如果ping超时则关闭连接并清空缓存

应该缓存什么?

总的来讲:

  • 更新频繁的key尽量不要缓存,更新越频繁通知客户端就越频繁
  • 尽量缓存热key,效果才明显
  • 缓存的key尽量是经常被用到且以合理的频率(频率越低越好,合理的意思是不太高)更新,关于更新频率可以考虑用INCR来统计

客户端缓存淘汰可以借鉴LRU/LFU

其他注意事项

  • 请求key最好把key的ttl也拿回来,并在本地内存设置差不多的ttl
  • 若果key没有ttl也要设置一个最大值的ttl,防止因错误连接等问题引发过时数据被访问问题
  • 一定要限制client内存的使用量,当有新key缓存时要驱逐旧key

限制server的内存使用

一定要给server的无效列表设置一个最大值,记录key数量的最大值,或者使用广播模式不占用任何内存。当不使用广播模式时,server占用的内存和追踪的key数量及这些key对应的客户端数量成正比