Go 分布式缓存简单实现

一个性能优异的系统架构,缓存是必不可少的。但在缓存的使用,可能会遇到一些问题,比如,内存不够、并发写入冲突、单机性能差等问题。为了解决这些问题,我们引入了分布式缓存。

本次分享主要通过学习极客兔兔 的文章,对关键节点进行了实现和解读。

缓存淘汰策略 LRU

  1. 首先,定义缓存数据结构,使用双向链表和字典进行数据存储和查找。

    // Cache 使用最近最少使用算法,并发访问不安全
    type Cache struct {
        maxBytes  int64                         // 允许使用的最大内存
        nbytes    int64                         // 当前已经使用的内存
        ll        *list.List                    // 双向链表
        cache     map[string]*list.Element      // 键是字符串、值是双向链表中对应节点的指针
        OnEvicted func(key string, value Value) // 可选参数,某条记录被移除时的回调函数,在清除条目时执行
    }
    
    
  2. 实现基本操作,包括增、删、查(改的复杂度较高,涉及到改的操作时,可以先删除,再新增)

    // Get 从字典中找到对应的双向链表的节点,并将节点移动到队尾
    func (c *Cache) Get(key string) (value Value, ok bool) {
        if ele, ok := c.cache[key]; ok { // 如果键对应的节点存在
            c.ll.MoveToFront(ele) // 将对应节点移动到队尾(双向链表作为队列,队首队尾是相对的,这里约定 front 为队尾),并返回查找到的值
            kv := ele.Value.(*entry)
            return kv.value, true
        }
        return
    }
    
    // RemoveOldest 删除,实际上是缓存淘汰,即移除最近最少访问的节点(队首)
    func (c *Cache) RemoveOldest() {
        ele := c.ll.Back() // 取队首节点,从链表中删除
        if ele != nil {
            c.ll.Remove(ele)
            kv := ele.Value.(*entry)
            delete(c.cache, kv.key)                         // 从字典中删除该节点的映射关系
            c.nbytes -= int64(len(kv.key) + kv.value.Len()) // 更新当前使用内存长度
            if c.OnEvicted != nil {                         // 当回调函数不为空时进行调用
                c.OnEvicted(kv.key, kv.value)
            }
        }
    }
    
    // Add 新增/修改
    func (c *Cache) Add(key string, value Value) {
        if ele, ok := c.cache[key]; ok { // 如果对应键存在,更新对应节点的值,并将该节点移到队尾
            c.ll.MoveToFront(ele)
            kv := ele.Value.(*entry)
            c.nbytes += int64(value.Len() - kv.value.Len()) // 更新内存
            kv.value = value
        } else { // 如果对应键不存在则是新增场景,
            ele := c.ll.PushFront(&entry{key: key, value: value}) // 队尾添加新节点,并在字典中添加 key 和节点的映射关系
            c.cache[key] = ele
            c.nbytes += int64(len(key) + value.Len()) // 更新内存
        }
        for c.maxBytes != 0 && c.maxBytes < c.nbytes { // 如果超过设定的最大值,移除最少访问的节点
            c.RemoveOldest()
        }
    }
    
    
  3. 提供 New 方法,在初始化时指定存储最大值和回调函数。

    func New(maxBytes int64, onEvicted func(string, Value)) *Cache {
        return &Cache{
            maxBytes:  maxBytes,
            ll:        list.New(),
            cache:     make(map[string]*list.Element),
            OnEvicted: onEvicted,
        }
    }
    
    

一致性哈希

一致性哈希的实现是基于哈希环实现的,主要为了解决数据倾斜和节点增删改时缓存雪崩的问题。

  1. 首先,定义数据结构。这里除了真实节点记录在哈希环上外,还引入了虚拟节点,形成虚拟节点到真实节点的映射,虚拟节点的个数和虚拟节点倍数相关。

    type Hash func(data []byte) uint32
    
    type Map struct {
        hash     Hash           // 哈希函数
        replicas int            // 虚拟节点倍数
        keys     []int          // 哈希环
        hashMap  map[int]string // 虚拟节点与真实节点的映射表, 键是虚拟节点的哈希值,值是真实节点的名称
    }
    
    
  2. 实现基本操作,新增和查询。

    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)
    }
    
    func (m *Map) Get(key string) string {
        if len(m.keys) == 0 {
            return ""
        }
        hash := int(m.hash([]byte(key)))                   // 计算 key 的哈希值
        idx := sort.Search(len(m.keys), func(i int) bool { // 顺时针找到第一个匹配的虚拟节点的下标 idx,从 m.keys 中获取到对应的哈希值
            return m.keys[i] >= hash
        })
        return m.hashMap[m.keys[idx%len(m.keys)]] // 因为 m.keys 是一个环状结构,所以用取余数的方式来处理这种情况
    }
    
    
  3. 提供 New 方法,在初始化时指定虚拟节点倍数和哈希函数。

    func New(replicas int, fn Hash) *Map {
        m := &Map{
            replicas: replicas,
            hash:     fn,
            hashMap:  make(map[int]string),
        }
        if m.hash == nil {
            m.hash = crc32.ChecksumIEEE // 默认为 crc32.ChecksumIEEE 算法
        }
        return m
    }
    
    

使用锁机制防止缓存击穿

缓存击穿指大量请求同时访问缓存中不存在数据,因为缓存中不存在这个数据,请求就会击穿到 DB,造成瞬时 DB 压力骤增。解决方案也很简单,针对相同的 key,在访问时,只发起一次请求。

  1. 首先,定义数据结构。这里的 sync.WaitGroup 是为了解决不同协程之间的消息传递, sync.Mutex 则是为了解决并发读写而加上的锁。

    // call 代表正在进行中,或已经结束的请求
    type call struct {
        wg  sync.WaitGroup
        val interface{}
        err error
    }
    
    // Group 管理不同 key 的请求(call)
    type Group struct {
        mu sync.Mutex
        m  map[string]*call
    }
    
    
  2. 控制实现。

    // Do 针对相同的 key,无论 Do 被调用多少次,函数 fn 都只会被调用一次,等待 fn 调用结束了,返回返回值或错误
    func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
        g.mu.Lock()
        if g.m == nil {
            g.m = make(map[string]*call)
        }
        if c, ok := g.m[key]; ok {
            g.mu.Unlock()
            c.wg.Wait()         // 如果请求正在进行中,则等待
            return c.val, c.err // 请求结束,返回结果
        }
        c := new(call)
        c.wg.Add(1)  // 发起请求前加锁
        g.m[key] = c // 添加到 g.m,表明 key 已经有对应的请求在处理
        g.mu.Unlock()
    
        c.val, c.err = fn() // 调用 fn,发起请求
        c.wg.Done()         // 请求结束
    
        g.mu.Lock()
        delete(g.m, key) // 更新 g.m
        g.mu.Unlock()
    
        return c.val, c.err // 返回结果
    }
    
    

节点之间通过 Protobuf 通信

Protobuf 的安装和使用教程可以参考官网,推荐使用 homebrew 进行安装。

  1. 创建并编辑 .proto 文件,进行数据结构的定义。

    syntax = "proto3";
    
    package geecachepb;
    option go_package = "./";
    
    message Request {
      string group = 1;
      string key = 2;
    }
    
    message Response {
      bytes value = 1;
    }
    
    service GroupCache {
      rpc Get(Request) returns (Response);
    }
    
    
  2. 使用 protoc 生成 Go 代码。

    protoc --go_out=. *.proto
    
    
  3. 在项目中使用生成的 Go 代码。

    import (
        pb "path/geecachepb"
    )
    
    func (h *httpGetter) Get(in *pb.Request, out *pb.Response) error {
        u := fmt.Sprintf("%v%v/%v", h.baseURL, url.QueryEscape(in.GetGroup()), url.QueryEscape(in.GetKey()))
        res, err := http.Get(u)
        if err != nil {
            return err
        }
        defer res.Body.Close()
    
        if res.StatusCode != http.StatusOK {
            return fmt.Errorf("server returned: %v", res.Status)
        }
    
        bytes, err := ioutil.ReadAll(res.Body)
        if err != nil {
            return fmt.Errorf("reading response body: %v", err)
        }
        if err = proto.Unmarshal(bytes, out); err != nil { // 使用 proto.Unmarshal() 解码 HTTP 响应
            return fmt.Errorf("decoding response body: %v", err)
        }
        return nil
    }
    
    

源代码👉这里

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,657评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,662评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,143评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,732评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,837评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,036评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,126评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,868评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,315评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,641评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,773评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,859评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,584评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,676评论 2 351

推荐阅读更多精彩内容