通常的缓存会放在应用和DB之间,比如redis。客户端缓存是指在应用服务内部再加一层缓存,也就是内存缓存,从而进一步提升访问速度。
client cache的问题是缓存应该何时失效,更确切的说是如何保持与远端数据的一致性。
为client cache设置过期时间是一个选择,但时间设置多久是一个问题。太长会有时效性问题,太短缓存的效果会打折扣。
redis在服务端记录访问的连接和相关的key, 当key有变化时,通知相应的连接(应用)。应用收到请求后自行处理有变化的key, 进而实现client cache与redis的一致。
redis对客户端缓存的支持方式被称为Tracking,分为两种模式:默认模式,广播模式。
Server 端记录每个Client访问的Key,当发生变更时,向client推送数据过期消息。
客户端订阅key前缀的广播(空串表示订阅所有失效广播),服务端记录key前缀与client的对应关系。当相匹配的key发生变化时,通知client。
redis6.0开始使用新的协议RESP3。该协议增加了很多数据类型。新协议目的之一是支持客户端缓存。想对新协议有更多了解可以参看如下两篇文章:
这里,我们只关注与客户端缓存相关的部分
使用Redis 6支持的新版Redis协议RESP3,可以在同一个连接中运行数据的查询和接收失效消息。不过,许多客户端实现可能倾向于使用两个单独的连接来实现客户端缓存:一个用于数据,另一个用于失效消息。redis通过redirect支持这一功能。
当一个客户端开启tracking后,它可以通过设置另一连接的“client id ”将失效的消息重定向(redirect)到另一个连接。多个数据连接可以将失效消息重定向到同一连接,这对于实现了连接池的客户端会很有用。
结合redirect和sub/pub,对于只支持resp2协议的client也可以实现对失效数据的接收。redis官方有介绍,这里不着重讲述了。有兴趣可以点击这里查看。
使用之前,你需要安装redis6.0。 注意,编译时需要gcc 6.0或以上版本。遗憾的是,6.0自带的redis-cli对RESP3的支持也不好,解析不了push数据>_<|||, 所以,想看push的真面目,用telnet吧!
telnet 1.1.1.1 6379
//开启RESP3
hello 3
%7
$6
server
$5
redis
$7
version
$5
6.0.6
$5
proto
:3
$2
id
:514
$4
mode
$10
standalone
$4
role
$6
master
$7
modules
*0
//开启tracking
client tracking on
+OK
get name
$4
ball
//在另一个client上改变name值, 当前连接收到push数据, name invalidate
>2
$10
invalidate
*1
$4
name
说明:
telnet 1.1.1.1 6379
//开启REPS3
hello 3
//节省篇幅,省略输出
... ...
//未加prefix, 接收所有失效广播
client tracking on bcast
+OK
//在另一个client上改变name值, 当前连接收到push数据, name invalidate
>2
$10
invalidate
*1
$4
name
我们构造如下场景
说明:
那么在web应用中该如何结合redis实现客户端缓存呢,我们有如下考量:
基于4的考量,感觉用go实现一个client side caching的demo是比较方便的。
我们找一个支持resp3的包。
go get github.com/stfnmllr/go-resp3/client
以下代码模拟了一个http服务,处理search请求。
package main
import (
"fmt"
"log"
"net/http"
"strings"
"github.com/stfnmllr/go-resp3/client"
)
//本地缓存
var localCache = make(map[string]string)
//请求处理函数
func search(w http.ResponseWriter, r *http.Request) {
tmp, ok := r.URL.Query()["key"]
if !ok || len(tmp) == 0 {
w.Write([]byte("param key err"))
return
}
key := strings.Join(tmp, "")
val, ok := localCache[key]
//localCache中无数据
if !ok {
dialer := new(client.Dialer)
conn, err := dialer.Dial("10.160.75.237:6379")
if err != nil {
w.Write([]byte(err.Error()))
}
defer conn.Close()
val, err = conn.Get(key).ToString()
if err != nil {
w.Write([]byte("no val"))
fmt.Printf("no val for key:%s\n", key)
return
}
//写localCache
localCache[key] = val
fmt.Printf("get from redis key:%s v:%s\n", key, val)
} else {
fmt.Printf("get from localcache key:%s v:%s\n", key, val)
}
w.Write([]byte(val))
}
func main() {
// Create connetion providing key invalidation callback.
dialer := new(client.Dialer)
//失效通知回调
dialer.InvalidateCallback = func(keys []string) {
for _, key := range keys {
delete(localCache, key)
fmt.Printf("clear localCache %s\n", key)
}
}
conn, err := dialer.Dial("10.160.75.237:6379")
if err != nil {
log.Fatal(err)
}
broadcast := true
if err := conn.ClientTracking(true, nil, nil, broadcast, false, false, false).Err(); err != nil {
log.Fatal(err)
}
http.HandleFunc("/search", search)
http.ListenAndServe(":8000", nil)
}
只是为了演示思路,所以代码是有不完善之处的, 比如
php-fpm的方式,不容易实现4中的思路。但想了想,似乎也有变通的方式。
官方Redis server-assisted client side caching
Redis系列(十四)、Redis6新特性之RESP3与客户端缓存(Client side caching)
go-resp3 doc