造轮子 | golang | 支持过期时间的LRU缓存

本文代码已上传github,欢迎交流。

最近在学习go语言,正好有遇到需要使用缓存的地方,于是决定自己造个轮子。主要特性如下:

  • 线程安全;
  • 支持被动触发的过期时间;
  • 支持key和value任意类型;
  • 基于双向链表和hash表实现;

双向链表的插入、删除和元素移动效率非常高,LRU缓存通常都有大量的以上操作。使用hash表来存储每个key对应的元素的指针,避免每次查询缓存都需要遍历整个链表,提高效率。

被动的过期的时间表示并不会主动的删除缓存中已经过期的元素,而是在需要使用的时候才去检查是否过期,如果过期的话再去删除。

数据结构

每个缓存的元素至少包含两个:缓存的关键字key、缓存的数据data;为了支持过期时间,每个元素还要有一个值来表示其过期时间;另外基于双向链表实现,还需要指向前一个元素和后一个元素的指针;于是,每个缓存元素的结构定义:

type elem struct {
    key        interface{}
    data       interface{}
    expireTime int64
    next       *elem
    pre        *elem
}

那么对于整个缓存来说,事实上就是一个个元素组成的列表,但是为了更高效的查询,使用一个hash表来存放key对应的元素的指针,提升查询效率,于是cache的结构定义:

type lrucache struct {
    maxSize   int
    elemCount int
    elemList  map[interface{}]*elem
    first     *elem
    last      *elem
    mu        sync.Mutex
}

保存链表首尾元素的指针是为了在淘汰元素和插入元素的时候更高效。

基本方法

一个缓存基本的方法应该包括新建缓存、添加元素、删除元素、查询元素。

新建缓存

新建一个缓存实际上就是新建一个lrucache结构体,并对里面的元素进行初始化:

// New create a new lrucache
// size: max number of element
func New(size int) (*lrucache, error) {
    newCache := new(lrucache)
    newCache.maxSize = size
    newCache.elemCount = 0
    newCache.elemList = make(map[interface{}]*elem)
    return newCache, nil
}

入参表示这个缓存最多能存放的元素的个数,当到达最大个数的时候就开始淘汰最久没使用的元素。

添加元素

添加元素使用Set方法来实现,如果缓存中已经存在该key,就更新值;否则新建一个缓存元素并保存。过期时间是可选的,如果没传入过期时间,这个元素就会一直存在知道被淘汰。

// Set create or update an element using key
//      key:    The identity of an element
//      value:  new value of the element
//      ttl:    expire time, unit: second
func (c *lrucache) Set(key interface{}, value interface{}, ttl ...int) error {

    // Ensure ttl are correct
    if len(ttl) > 1 {
        return errors.New("wrong para number, 2 or 3 expected but more than 3 received")
    }
    var elemTTL int64
    if len(ttl) == 1 {
        elemTTL = int64(ttl[0])
    } else {
        elemTTL = -1
    }

    c.mu.Lock()
    defer c.mu.Unlock()

    if e, ok := c.elemList[key]; ok {
        e.data = value
        if elemTTL == -1 {
            e.expireTime = elemTTL
        } else {
            e.expireTime = time.Now().Unix() + elemTTL
        }
        c.mvKeyToFirst(key)
    } else {
        if c.elemCount+1 > c.maxSize {
            if c.checkExpired() <= 0 {
                c.eliminationOldest()
            }
        }
        newElem := &elem{
            key:        key,
            data:       value,
            expireTime: -1,
            pre:        nil,
            next:       c.first,
        }
        if elemTTL != -1 {
            newElem.expireTime = time.Now().Unix() + elemTTL
        }
        if c.first != nil {
            c.first.pre = newElem
        }
        c.first = newElem
        c.elemList[key] = newElem

        c.elemCount++
    }
    return nil
}

如果一个key已经存在就更新它所对应的值,并将这个key对应的元素移动到链表的最前面;如果key不存在就需要新建一个链表元素,流程如下:

新增key流程图

由于采用的是过期时间是被动触发的方式,因此在元素满的时候并不能确定是否存在过期的元素,因此目前采用的方式是,当满了之后每次新增元素就去遍历的检查一次过期的元素,时间复杂度为O(n),感觉这种实现方式不太好,但是目前没想到更好的实现方式。

上面使用到的内部方法实现如下:

// updateKeyPtr 更新对应key的指针,放到链表的第一个
func (c *lrucache) mvKeyToFirst(key interface{}) {
    elem := c.elemList[key]
    if elem.pre == nil {
        // 当key是第一个元素时,不做动作
        return
    } else if elem.next == nil {
        // 当key不是第一个元素,但是是最后一个元素时,提到第一个元素去
        elem.pre.next = nil

        c.last = elem.pre

        elem.pre = nil
        elem.next = c.first
        c.first = elem

    } else {
        elem.pre.next = elem.next
        elem.next.pre = elem.pre

        elem.next = c.first
        elem.pre = nil
        c.first = elem
    }
}

func (c *lrucache) eliminationOldest() {
    if c.last == nil {
        return
    }
    if c.last.pre != nil {
        c.last.pre.next = nil
    }
    key := c.last.key
    c.last = c.last.pre
    delete(c.elemList, key)
}

func (c *lrucache) deleteByKey(key interface{}) {
    if v, ok := c.elemList[key]; ok {
        if v.pre == nil && v.next == nil {
            // 当key是第一个元素时,清空元素列表,充值指针和元素计数
            c.elemList = make(map[interface{}]*elem)
            c.elemCount = 0
            c.last = nil
            c.first = nil
            return
        } else if v.next == nil {
            // 当key不是第一个元素,但是是最后一个元素时,修改前一个元素的next指针并修改c.last指针
            v.pre.next = v.next
            c.last = v.pre
        } else if v.pre == nil {
            c.first = v.next
            c.first.pre = nil
        } else {
            // 中间元素,修改前后指针
            v.pre.next = v.next
            v.next.pre = v.pre
        }
        delete(c.elemList, key)
        c.elemCount--
    }
}

// 遍历链表,检查并删除已经过期的元素
func (c *lrucache) checkExpired() int {
    now := time.Now().Unix()
    tmp := c.first
    count := 0
    for tmp != nil {
        if tmp.expireTime != -1 && now > tmp.expireTime {
            c.deleteByKey(tmp.key)
            count++
        }
        tmp = tmp.next
    }
    return count
}

获取元素

使用Get方法来获取尝试获取一个缓存的元素,在获取的时候同时会检查是否过期,如果过期的话会返回响应的错误并删掉该元素:

// Get Get the value of a cached element by key. If key do not exist, this function will return nil and a error msg
//      key:    The identity of an element
//      return:
//          value:  the cached value, nil if key do not exist
//          err:    error info, nil if value is not nil
func (c *lrucache) Get(key interface{}) (value interface{}, err error) {
    if v, ok := c.elemList[key]; ok {
        if v.expireTime != -1 && time.Now().Unix() > v.expireTime {
            // 如果过期了
            c.deleteByKey(key)
            return nil, errors.New("the key was expired")
        }
        c.mvKeyToFirst(key)
        return v.data, nil
    }
    return nil, errors.New("no value found")
}

删除元素

删除元素通过Delete来实现,实际上在之前的内部方法中已经实现了删除一个元素的功能,只需要封装给外部调用即可:

// Delete delete an element
func (c *lrucache) Delete(key interface{}) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    if _, ok := c.elemList[key]; !ok {
        return errors.New(fmt.Sprintf("key %T do not exist", key))
    }
    c.deleteByKey(key)
    return nil
}

算是熟悉了go语言的基本使用,但是还有很多需要优化的地方,比如优化Set方法的效率,使用读写锁替换互斥锁。。。。
欢迎讨论。

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

推荐阅读更多精彩内容