❝先赞后看,年薪百万~~~ 官方文档翻译总结,如有错误请指正
❞
通常应用服务(application)需要数据时都从redis(database)服务中去获取:
+-------------+ +----------+
| | ------- GET user:1234 -------> | |
| Application | | Database |
| | <---- username = Alice ------- | |
+-------------+ +----------+
当使用客户端缓存后,应用程序会把热查询的结果直接放到应用程序的本地内存中,之后再用到这些查询时直接从本地内存取,无需再次访问redis服务:
+-------------+ +----------+
| | | |
| Application | ( No chat needed ) | Database |
| | | |
+-------------+ +----------+
| Local cache |
| |
| user:1234 = |
| username |
| Alice |
+-------------+
对「热key」效果非常明显,大大降低redis服务端压力
客户端缓存优点:
- 延迟非常小,省去了访问redis服务的网络开销
- 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」 来实现客户端缓存失效通知及要通知哪些客户端,它有两个模式:
- 默认模式,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的客户端也会被通知失效。为的是减少内存使用,减少实现的复杂度
广播模式,通常不会去记每个客户端缓存的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为前提的,所以:
- 当客户端倾向于使用淘汰旧key的策略来缓存时,这样做会更有效率
- 服务器将被迫保留有关客户端key的更多数据
- 客户端将收到有关其未缓存的对象的无用的无效消息
指定客户端要缓存的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的单链接模式时即一个连接即用来数据传输又用来接收无效通知,此时是不存在这种不安全问题的,因为单链接场景中,所有的数据和通知都是按顺序的(按顺序就意味着会阻塞,这也是单连接的一个问题吧?)
意外断开连接会发生什么问题吗
当仅有的一个失效通知连接断开了,那对应客户端的本地缓存岂不是没法实时通知更新了?以下行为可以避免此问题:
- 当失效通知连接断开后,客户端会强制清空本地所有缓存
- 当使用 「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对应的客户端数量成正比