三、HTTP服务
分布式缓存需要实现节点间通信,下面我们就来为单机节点搭建HTTP Server,以下是geecache/http.go
的代码:
const defaultBasePath = "/_geecache/"
type HTTPPool struct {
self string
basePath string
}
func c(self string) *HTTPPool {
return &HTTPPool{
self: self,
basePath: defaultBasePath,
}
}
代码创建了结构体 HTTPPool
,作为承载节点间HTTP通信的核心数据结构,self用来记录自己的地址,包括主机名/IP 和端口,basePath作为节点间通讯地址的前缀,默认是 /_geecache/
,下面实现最核心的ServeHTTP()
:
func (p *HTTPPool) Log(format string, v ...interface{}) {
log.Printf("[Server %s] %s", p.self, fmt.Sprintf(format, v))
}
func (p *HTTPPool) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if !strings.HasPrefix(req.URL.Path, p.basePath) {
fmt.Printf("HTTPPool serving unexpected path: " + req.URL.Path)
}
p.Log(req.Method, req.URL.Path)
parts := strings.SplitN(req.URL.Path[len(p.basePath):], "/", 2)
if len(parts) != 2 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
groupName := parts[0]
key := parts[1]
group := GetGroup(groupName)
if group == nil {
http.Error(w, "no such group:"+groupName, http.StatusNotFound)
return
}
view, err := group.Get(key)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(view.ByteSlice())
}
这段代码的逻辑不算复杂,首先检查请求路径的URL是否以basePath为前缀,接着在Path中从basePath的结尾开始,以"/"开始切割,例如basePath为"/cache/"
,而URL路径为"/cache/user/key"
,则parts中的数据就是["user","key"]。如果成功切出来两段内容,那么第一段就是groupName,第二段为key,利用groupName获取到group,再利用key获取到group中的view写入HTTP响应中,并设置Content-Type头字段为"application/octet-stream",表示该响应的内容是字节流。
接着main.go
进行测试!
var db = map[string]string{
"Tom": "630",
"Jack": "589",
"Sam": "567",
}
func main() {
geecache.NewGroup("scores", 2<<10, geecache.GetterFunc(
func(key string) ([]byte, error) {
log.Printf("[SlowDB] search key", key)
if v, ok := db[key]; ok {
return []byte(v), nil
}
return nil, fmt.Errorf("%s not exist", key)
}))
addr := "localhost:9999"
peers := geecache.NewHTTPPool(addr)
log.Println("geecache is running at", addr)
log.Fatal(http.ListenAndServe(addr, peers))
}
四、一致性哈希
为了实现分布式缓存,一致性哈希是我们必须要考虑的问题,因此首先讲解这个部分。
1.不太健全的哈希
假设这样一个场景,我们现在拥有10个节点,请求发送给节点X,失败,因为它没有存储此缓存值,于是向其他节点寻求帮助,由该节点从数据源获取数据。节点X随机寻求了节点A的帮助,由A去获取此缓存值,此次请求结束。
但是问题来了,同样的请求再一次发送给了节点X,如何能让节点X有记忆一般的,再次去找节点A要数据,而不是向其他节点要呢?
针对这个需求,我们可以设计如下算法:
将请求中key的每个字符取ASCII码,将其相加后除以10取余数,这样就一定会得到一个小于10的数,余数为几,就去找几号节点要数据,这样对于固定的key,被请求的节点总会去找一个固定的节点寻求帮助,形成了类似"记忆"的逻辑。
可此时由于财务亏损,节点的数量由10缩减到了8,本来除以10取余数的操作,要变成除以8取余数,也就是以前10个节点时形成的"记忆"全部失效,缓存全部失效,严重时可能会造成瞬时DB请求量大、压力骤增,导致缓存雪崩的情况。
那要如何解决呢?这就要采用一致性哈希算法了。
2.一致性哈希算法
上述不太健全的算法对于key的处理是:取key中每个字符ASCII码的和,除以节点的总数量,得到余数。一致性哈希算法对于key的处理是,将其映射到一个2^32的空间中,并将这些数字首尾相连形成环,如图所示:
将节点放到环上,对key的请求进行映射后也放到环上,顺时针寻找到的第一个节点,就是应选取的节点。例如对key2的请求要寻找节点A,对key1的请求要寻找节点B,对key4的请求要寻找节点C,对key3的请求要寻找节点A。
目前我们实现了节点的"记忆"功能,即节点接收到请求后,会对于相同的key,会固定的向一个节点寻求帮助。
现在由于财务亏损,我们关掉了节点B,这时也仅仅只影响到了对key1的请求,它由对应节点B变成了对应节点C,其他节点没有任何改变,不用去重新"记忆",避免了缓存雪崩的情况。
业务做大了,赚到钱了,要在这个基础上新加一个节点D,如图所示:
那么也仅仅只有对key4的请求由节点C改变到节点D,其他的节点依旧没有任何影响,这便是一致性哈希算法。当然,它也有一些缺陷:
设想这样一个场景,目前有三个节点,经过映射后全部处于圆的上半部分,那么下半圆的所有key都只会对应上半圆中最左边的节点,导致它压力过大,其他节点空闲,导致缓存在负载不均衡。
解决这个办法也很简单,可以引入虚拟节点,即一个真实节点对应多个虚拟节点。例如节点A对应虚拟节点A1、A2、A3,其余节点也做同样的操作,这样就相当于扩充了节点的数量,解决了数据倾斜的问题,而且也只需要创建一个map维护真实节点和虚拟节点之间的映射即可。
3.实现一致性哈希算法
我们在geecache/consistenthash/consistenthash.go
中实现一致性哈希算法:
type Hash func(data []byte) uint32
type Map struct {
hash Hash
replicas int
keys []int
hashMap map[int]string
}
func New(replicas int, fn Hash) *Map {
m := &Map{
hash: fn,
replicas: replicas,
hashMap: make(map[int]string),
}
if m.hash == nil {
m.hash = crc32.ChecksumIEEE
}
return m
}
下面听俺逐个解释:
- 函数
Hash
:用来将数据映射到圆环上,如果不自定义,默认采用crc32.ChecksumIEEE
算法; - 结构体
Map
,主要包括四个字段:
hash:表示哈希函数,即上面介绍的。
replicas:表示虚拟节点的个数。
keys:存储所有虚拟节点的编号,类型为 []int,是一个有序的整数数组。
hashMap:存储所有虚拟节点到真实节点的映射关系,其中 int 表示虚拟节点的编号,string 表示真实节点的地址。
接下来实现添加真实节点的Add方法:
func (m *Map) Add(keys ...string) {
for _, key := range keys {
for i := 0; i < m.replicas; i++ {
hash := int(m.hash([]byte(strconv.Itoa(i) + key)))
m.keys = append(m.keys, hash)
m.hashMap[hash] = key
}
}
sort.Ints(m.keys)
}
此方法允许传入多个真实节点的名称,并对每一个真实节点key
,创建m.replicas
个虚拟节点,并将虚拟节点的名字设置为编号+key
取哈希后的值,接着添加到圆环中,并把真实节点和虚拟节点的映射关系添加到m.hashMap
中。最后,对环上的节点进行哈希值排序。
下面实现节点选择的Get方法:
func (m *Map) Get(key string) string {
if len(m.keys) == 0 {
return ""
}
hash := int(m.hash([]byte(key)))
idx := sort.Search(len(m.keys), func(i int) bool {
return m.keys[i] >= hash
})
return m.hashMap[m.keys[idx%len(m.keys)]]
}
该方法首先计算出传入 key 的哈希值,也就是将 key 转成字节数组,再通过哈希函数Hash计算出哈希值;接着通过sort.Search()
方法在有序的m.keys
列表中查找到接近且大于等于该哈希值的第一个虚拟节点的下表,即顺时针后找到的第一个虚拟节点,这里使用了匿名函数func(i int) bool {}
实现二分查找算法;最后通过m.hashMap
映射该虚拟节点下表对应的真实节点,如果 idx == len(m.keys)
,说明应选择 m.keys[0]
,因为 m.keys
是一个环状结构,所以用取余数的方式来处理这种情况。
至此,我们实现了整个的一致性哈希算法!
五、分布式节点
先回顾一下之前画的流程:
(1)和(3)已经实现,还剩(2)需要补充,让我们把(2)细化一些:
1.抽象PeerPicker
在geecache/peers.go
编写如下代码:
type PeerPicker interface {
PickPeer(key string) (peer PeerGetter, ok bool)
}
type PeerGetter interface {
Get(group string, key string) ([]byte, error)
}
PeerPicker
接口定义了一个用于选择节点的方法PickPeer(),它是一个抽象的概念,可以是一个HTTP服务的实现,也可以是一个节点间通过 P2P 协议通信的实现,只要符合定义的方法并实现了对应的接口即可。
PeerGetter
接口定义了一个用于获取缓存数据的方法Get(),它同样是一个抽象的概念,具体的实现可以是从本地内存或磁盘中获取缓存数据,也可以是从其他节点获取远程缓存数据,只要符合定义的方法并实现了对应的接口即可。
将这两个接口独立出来实现了缓存系统的高可扩展性和异构性。可以通过PeerPicker
接口选择对应数据所在的节点,再通过PeerGetter
接口从对应的节点中获取数据,从而有效地实现了数据的分布式存储和读取。
2.节点选择与 HTTP 客户端
上文编写的geecache/http.go
只实现了服务端功能,通信不仅需要服务端还需要客户端,因此,接下来要为 HTTPPool
实现客户端的功能:
type httpGetter struct {
baseURL string
}
func (h *httpGetter) Get(group string, key string) ([]byte, error) {
u := fmt.Sprintf(
"%v%v/%v",
h.baseURL,
url.QueryEscape(group),
url.QueryEscape(key),
)
res, err := http.Get(u)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned: %v", res.Status)
}
bytes, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %v", err)
}
return bytes, nil
}
var _ PeerGetter = (*httpGetter)(nil)
这段代码定义了一个名为httpGetter的结构体和它的方法Get(),并实现了PeerGetter接口。详细解释如下:
定义了 httpGetter 结构体,它有一个字符串类型的字段 baseURL,表示请求的基本 URL 地址。
Get()
方法使用 http 协议从远程节点获取缓存数据,接收两个参数group和key,表示数据所属的分组和数据的键值。它根据baseURL、group、key等信息构造出一个完整的URL地址u,然后通过http.Get()方法发起一个GET请求,并从响应中读取数据。
如果链接成功,返回的响应码为http.StatusOK(即 200),则通过ioutil.ReadAll()方法读取响应体中的所有数据,最后返回读取到的字节数组和一个nil错误。
如果链接发生错误,或者响应状态码表明请求失败,则返回一个自定义的错误信息,表示读取失败或服务端返回错误。
最后一行代码是 var _ PeerGetter = (*httpGetter)(nil)
,它的作用是将httpGetter
类型转换为PeerGetter
接口类型。这么做的原因是PeerGetter
是一个接口类型,无法直接实例化,因此我们需要向这个接口类型注册并实现相应的方法,最终得到一个完整的实例。在这里我们通过将httpGetter
转换为PeerGetter
接口类型,实现了httpGetter
在PeerGetter
接口中的注册。
然后为为HTTPPool
添加节点选择的功能:
const (
defaultBasePath = "/_geecache/"
defaultReplicas = 50
)
type HTTPPool struct {
self string
basePath string
mu sync.Mutex
peers *consistenthash.Map
httpGetters map[string]*httpGetter
}
新增字段peers
,类型是一致性哈希算法的 Map
,用来根据具体的 key 选择节点
新增字段httpGetters
,映射远程节点与对应的httpGetter。每一个远程节点对应一个httpGetter,因为httpGetter与远程节点的地址 baseURL
有关。
然后实现PeerPicker
接口:
func (p *HTTPPool) Set(peers ...string) {
p.mu.Lock()
defer p.mu.Unlock()
p.peers = consistenthash.New(defaultReplicas, nil)
p.peers.Add(peers...)
p.httpGetters = make(map[string]*httpGetter, len(peers))
for _, peer := range peers {
p.httpGetters[peer] = &httpGetter{baseURL: peer + p.basePath}
}
}
func (p *HTTPPool) PickPeer(key string) (PeerGetter, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if peer := p.peers.Get(key); peer != "" && peer != p.self {
p.Log("Pick peer %s", peer)
return p.httpGetters[peer], true
}
return nil, false
}
var _ PeerPicker = (*HTTPPool)(nil)
Set()
方法将收到的节点传进一致性哈希算法,进行实例化;
PickPeer()
方法对一致性哈希的Get()
方法进一步封装,从节点哈希表中获取到该key所属的节点peer,返回节点对应的HTTP客户端。
至此,HTTPPool既具备了提供 HTTP 服务的能力,也具备了根据具体的 key,创建 HTTP 客户端从远程节点获取缓存值的能力。
接着,将上述新增的功能集成在主流程geecache/geecache.go
中:
type Group struct {
name string
getter Getter
mainCache cache
peers PeerPicker
}
func (g *Group) RegisterPeers(peers PeerPicker) {
if g.peers != nil {
panic("RegisterPeerPicker called more than once")
}
g.peers = peers
}
func (g *Group) load(key string) (value ByteView, err error) {
if g.peers != nil {
if peer, ok := g.peers.PickPeer(key); ok {
if value, err = g.getFromPeer(peer, key); err == nil {
return value, nil
}
log.Println("[GeeCache] Failed to get from peer", err)
}
}
return g.getLocally(key)
}
func (g *Group) getFromPeer(peer PeerGetter, key string) (ByteView, error) {
bytes, err := peer.Get(g.name, key)
if err != nil {
return ByteView{}, err
}
return ByteView{b: bytes}, nil
}
RegisterPeers()
方法将实现了PeerPicker接口的HTTPPool注入到Group中;
getFromPeer()
方法实现了PeerGetter接口的httpGetter从访问远程节点,获取缓存值;
load()
方法,使用 PickPeer()
方法选择节点,若非本机节点,则调用 getFromPeer()
从远程获取。若是本机节点或失败,则回退到 getLocally()
。
最后测试,编写main.go
:
var db = map[string]string{
"Tom": "630",
"Jack": "589",
"Sam": "567",
}
func createGroup() *geecache.Group {
return geecache.NewGroup("scores", 2<<10, geecache.GetterFunc(
func(key string) ([]byte, error) {
log.Println("[SlowDB] search key", key)
if v, ok := db[key]; ok {
return []byte(v), nil
}
return nil, fmt.Errorf("%s not exist", key)
}))
}
func startCacheServer(addr string, addrs []string, gee *geecache.Group) {
peers := geecache.NewHTTPPool(addr)
peers.Set(addrs...)
gee.RegisterPeers(peers)
log.Println("geecache is running at", addr)
log.Fatal(http.ListenAndServe(addr[7:], peers))
}
func startAPIServer(apiAddr string, gee *geecache.Group) {
http.Handle("/api", http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
view, err := gee.Get(key)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(view.ByteSlice())
}))
log.Println("fontend server is running at", apiAddr)
log.Fatal(http.ListenAndServe(apiAddr[7:], nil))
}
func main() {
var port int
var api bool
flag.IntVar(&port, "port", 8001, "Geecache server port")
flag.BoolVar(&api, "api", false, "Start a api server?")
flag.Parse()
apiAddr := "http://localhost:9999"
addrMap := map[int]string{
8001: "http://localhost:8001",
8002: "http://localhost:8002",
8003: "http://localhost:8003",
}
var addrs []string
for _, v := range addrMap {
addrs = append(addrs, v)
}
gee := createGroup()
if api {
go startAPIServer(apiAddr, gee)
}
startCacheServer(addrMap[port], []string(addrs), gee)
}
在这里稍微发点牢骚,其实从二、单机并发缓存
开始,我对整个项目的理解就没那么透彻了,属于在一知半解的状态下,代码逻辑吧也能缕缕顺,但是自己敲不出来,也想不出来人家为什么这么写。而这个五、分布式节点
,我就更搞不懂了,理解就更少了,但是看来看去好像就是不停的对最底层的数据结构进行封装,然后再用一个东西封装以下已经封装好的底层数据结构,这么做肯定有它的意义,但是我真的说不出来,靠自己啃貌似是不行了,应该需要一个牛逼的大佬点拨我那么一下。容我那么小emo一下,铁子们,属实太废物了。
六、防止缓存击穿
1.解释概念
首先对缓存雪崩、缓存击穿、缓存穿透的概念做个解释:
缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
缓存击穿:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。
缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
2.具体实现
设想这样一种情况,我们运行了三个节点,分别占用8001、8002、8003端口,在查询某个key的时候,并发了三个请求指向8001,假如有10万个在并发请求该数据呢?那就会向 8001 同时发起10万次请求,如果8001又同时向数据库发起10万次查询请求,很容易导致缓存被击穿。那这种情况下,我们如何做到只向远端节点发起一次请求呢?
下面来编写geecache/singleflight/singleflight.go
解决这个问题:
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
type Group struct {
mu sync.Mutex
m map[string]*call
}
call
用于表示一次函数调用的结果,避免重复的函数调用,在需要时可以直接从缓存中读取数据,减少了函数调用的次数;
Group
用于存储函数调用的缓存结果,管理不同 key 的请求(call)。
下面实现Do()方法:
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
// 1. 加锁,保证并发安全
g.mu.Lock()
// 2. 如果 m 没有初始化,先初始化一下
if g.m == nil {
g.m = make(map[string]*call)
}
// 3. 判断 Key 是否存在于 m 中,如果存在直接返回对应项的 value
if c, ok := g.m[key]; ok {
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err
}
// 4. Key 不存在则初始化一个新事件,并更新 m 中的映射关系
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
// 计算函数 fn,并更新 call 结构体中保存的计算结果和错误信息
c.val, c.err = fn()
// 通知等待组,计算完成
c.wg.Done()
g.mu.Lock()
// 5. 删除已缓存的key-value,在后续的调用中,从缓存中获取到的key将会是第一次添加key,以此保证对于一个key,只计算一次结果加入缓存
delete(g.m, key)
g.mu.Unlock()
// 返回计算结果
return c.val, c.err
}
个人觉得Do()
方法比较复杂,让chatgpt给我讲了一下:对于每一个输入的 key 值,Group 会维护一个 call 结构体,其中储存了函数调用的结果。当调用 Group 的 Do 方法时,如果该 key 对应的结果已经被计算过并缓存了,则直接返回结果;如果没有被缓存,则调用传入的函数 fn 计算结果,并将计算的结果缓存下来,下一次再调用该函数时可以直接取出缓存的结果返回。
接下来在geecache/geecache.go
中使用singleflight
:
type Group struct {
name string
getter Getter
mainCache cache
peers PeerPicker
loader *singleflight.Group
}
func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
// ...
g := &Group{
// ...
loader: &singleflight.Group{},
}
return g
}
func (g *Group) load(key string) (value ByteView, err error) {
viewi, err := g.loader.Do(key, func() (interface{}, error) {
if g.peers != nil {
if peer, ok := g.peers.PickPeer(key); ok {
if value, err = g.getFromPeer(peer, key); err == nil {
return value, nil
}
log.Println("[GeeCache] Failed to get from peer", err)
}
}
return g.getLocally(key)
})
if err == nil {
return viewi.(ByteView), nil
}
return
}
首先在Group
中添加刚刚定义的新字段,然后修改NewGroup
方法,初始化时也初始化loader
;
接着对load()
方法进行修改,用Do方法包裹起原来的逻辑,即实现了并发场景下对于相同的key,只调用一次。
到这里防止缓存击穿的部分就实现完毕了,我觉得自己越来越不行了,属实是一点都看不懂了,这可咋整啊,纯纯大废物一个。不过博客都写到这了,就硬着头皮收个尾吧,然后再回过头来钻研,也很希望能有大佬帮我顺顺,呜呜呜。
七、使用Protobuf通信
1.Protobuf
下面给一段关于Protobuf
的官方说明:
Protobuf,全称为 Protocol Buffers,是谷歌公司开发的一种高效、灵活、可扩展的序列化数据结构的协议,常用于数据存储、通信协议等领域。它既可以用于语言之间的数据交换,也可以用于持久化数据。Protobuf 的设计目标是简单、高效、自描述,支持多种编程语言,如 C++, Java, Python 等。
- 它的优点包括:与 XML 和 JSON 相比,序列化后的数据相对更小,传输效率更高
- 无须 XML 或 JSON 解析器,因此解析速度更快,资源消耗更少
- 支持向后和向前兼容,有利于协议的演化和扩展
- 支持多种语言,且跨语言支持更出色
使用 Protobuf,首先需要编写 .proto 文件来定义数据结构,然后使用编译器将其编译成目标代码。编译器生成的目标代码包含了从原始数据结构到二进制数据的映射,以及从二进制数据到原始数据结构的映射。这种映射通过操作字节码进行操作,实现了数据的序列化和反序列化。
用我自己的理解来说,就是和JSON一样的东西,但是比JSON牛逼很多,用它就完了。
2.使用Protobuf通信
这一块的具体实现暂且搁置吧,毕竟功能已经全部实现完了,改用protobuf不是太着急的事情,我想先把前面的代码好好消化消化,然后可以专门写一篇关于protobuf的博客,再来把这个坑填上。