内存缓存系统

胤凯 (oyto.github.io),欢迎到我的博客阅读。

今天我们围绕一个面试题来实现一个内存缓存系统。

面试题内容

1. 支持设置过期时间,精度到秒
2. 支持设置最大内存,当内存超出时做出合理的处理
3. 支持并发安全
4. 按照以下接口要求实现

type Cache interface {
    // SetMaxMemory size : 1KB 100KB 1MB 2MB 1GB
    SetMaxMemory(size string) bool
    // Set 将 value 写入缓存
    Set(key string, val interface{}, expire time.Duration) bool
    // Get 根据 key 值获取 value
    Get(key string) (interface{}, bool)
    // Del 删除 key 值
    Del(key string) bool
    // Exists 判断 key 是否存在
    Exists(key string) bool
    // Flush 清空所有 key
    Flush() bool
    // Keys 获取缓存中所有 key 的数量
    Keys() int64
}

5. 使用示例

cache := NewMemCache()
cache.SetMaxMemory("100MB")
cache.Set("int", 1)
cache.Set("bool", false)
cache.Set("data", map[string]interface{}{"a": 1})
cache.Get("int")
cache.Del("int")
cache.Flush()
cache.Keys()

题目分析

​    题目乍一看没有什么难点,就依据题目实现对应的接口以及对应的方法就行了。但其实有一个坑,那就是接口中的 `Set` 方法参数和使用示例中的对不上,使用示例中没有传入过期时间。难道是题目出错了?

​    显然不是的,这里是需要我们去做一个代理层,去实现一个可选参数的 `Set` 方法。我们可以在实现了带过期时间参数的方法后,再去封装一层,然后设置成可选参数即可。

​    这样子,题目的要求,就差不多没什么问题了。但这是面试题,我们想要面试官对我们的评价更好,就要做到更多的内容,寻找一些加分项,比如我们可以增加一个功能,定期删除过期缓存键,又或者我们可以写一些单元测试,让面试官知道我们有写单元测试的好习惯,这些都是一些加分项,能让我们更加地突出。

动手写代码

​    下面就带着大家一步一步来完成这个缓存系统,当然只是一个具有基础的功能缓存系统,大家在后续也可以自行在其中丰富更多的功能。

构建大体框架

​    首先,我们可以先在项目根目录下创建一个 cache 包,然后在 cache 包里创建一个 cache.go 文件,然后将题目中要求实现的接口放在里面:

// cache/cache.go
type Cache interface {
    // SetMaxMemory size : 1KB 100KB 1MB 2MB 1GB
    SetMaxMemory(size string) bool
    // Set 将 value 写入缓存
    Set(key string, val interface{}, expire time.Duration) bool
    // Get 根据 key 值获取 value
    Get(key string) (interface{}, bool)
    // Del 删除 key 值
    Del(key string) bool
    // Exists 判断 key 是否存在
    Exists(key string) bool
    // Flush 清空所有 key
    Flush() bool
    // Keys 获取缓存中所有 key 的数量
    Keys() int64
}

​    接着我们再在 cache 包下创建一个 memCache.go 文件,并在该文件中创建一个 memCache 结构体,去实现题目中要求的 Cache 接口:

type memCache struct {
}

func (mc *memCache) SetMaxMemory(size string) bool {
    return false
}

// Set 将 value 写入缓存
func (mc *memCache) Set(key string, val interface{}, expire time.Duration) bool {
    return false
}

// Get 根据 key 值获取 value
func (mc *memCache) Get(key string) (interface{}, bool) {
    return nil, false
}

// Del 删除 key 值
func (mc *memCache) Del(key string) bool {
    return true
}

// Exists 判断 key 是否存在
func (mc *memCache) Exists(key string) bool {
    return true
}

// Flush 清空所有 key
func (mc *memCache) Flush() bool {
    return true
}

// Keys 获取缓存中所有 key 的数量
func (mc *memCache) Keys() int64 {
    return 0
}

​    可以看到使用样例中有一个 NewMemCache 函数,于是我们还需要在 memCache.go 文件中添加一个 NewMemCache() 函数,返回一个实例,供 main 函数调用:

// cache/memCache.go
func NewMemCache() Cache {
    return &memCache{}
}


​    接着,我们就可以先去主函数 main 中用使用示例跑一下,看看有没有什么问题。在项目根目录下创建一个 main 函数,如下:

// main.go
package main

import cache2 "main/cache"

func main() {
    cache := cache2.NewMemCache()
    cache.SetMaxMemory("100MB")
    cache.Set("int", 1)
    cache.Set("bool", false)
    cache.Set("data", map[string]interface{}{"a": 1})
    cache.Get("int")
    cache.Del("int")
    cache.Flush()
    cache.Keys()
}

​    然后你就会发现报错了,这个问题就是我们上面说的那个坑,这里需要做一个代理层,但为了方便我们可以先修改使用样例,使他能够先跑通,最后再来做一个代理就可以了。

// main.go
cache.Set("int", 1, 3)
cache.Set("bool", false, 1)
cache.Set("data", map[string]interface{}{"a": 1}, 2)

​    为了看出效果,我们可以在所有的方法中都打印一个信息,比如 Set 方法中打印 `fmt.Println("我是 Set 方法")。

​    最后如果所有的方法都打印出了对应的信息,就说明这个大体框架我们已经搭建好了,下面再去慢慢实现各个方法就可以了。

逐个实现方法

​    下面就带着大家逐个实现每个具体的方法:

SetMaxMemory

​    这个方法是用于设置我们缓存系统的最大缓存大小的,因此我们的 memCache 结构体中,就至少应该需要两个字段:最大内存大小 和当前内存大小,因为我们肯定需要去判断当前内存是否超过了最大内存大小,为了方便,我们再增加一个 最大内存大小字段的字符串表示,如下:

// cache/memCache.go
type memCache struct {
    // 最大内存大小
    maxMemorySize int64
    // 最大内存字符串表示
    maxMemorySizeStr string
    // 当前内存大小
    currMemorySize int64
}

​    然后再来看我们的题目要求,`SetMaxMemory size : 1KB 100KB 1MB 2MB 1GB` 要求支持多种单位的表示,所以这里我们肯定需要对输入的内存大小做一个转换,因此我们需要去实现一个 parseSize 函数去解析用户的输入。

​    我们在 cache 包下,创建一个 util.go 文件,用于存放一些通用功能和工具函数。

​    parseSize 函数的实现思路是:将用户输入的字符串中的数字部分和单位部分分别提取出来,再进行校验和单位转换等的处理。

​    利用正则表达式先将用户的输入中的数字部分提取出来,然后再将用户输入的字符串中的数字部分用空格替换,这样剩下的部分就是单位了。

​    同样的,将用户输入字符串中的单位部分用空格替换,得到的就是数字部分了。

​    接下来,就是对用户的单位做一个转换,这里我们利用 Go 语言中的预定义标识符,采用小技巧来做一个单位的转换,如下:

// cache/util.go
const (
    B = 1 << (iota * 10) // 1
    KB                      // 2024
    MB                   // 1048576
    GB                   // 1073741824
    TB                      // ...
    PB                      // ...
)

​    有了不同的单位,我们就可以对解析出来的单位进行处理了,我们这里统一将所有的单位转换成字节,也就是 B:

// cache/util.go
var byteNum int64 = 0
// 1KB 100KB 1MB 2MB 1GB,单位统一为 byte
switch unit {
case "B":
    byteNum = num
case "KB":
    byteNum = num * KB
case "MB":
    byteNum = num * MB
case "GB":
    byteNum = num * GB
case "TB":
    byteNum = num * TB
case "PB":
    byteNum = num * PB
default: // 设置不合法,设置为 0,后续设置为 默认值
    num = 0
}

​    如果用户输入的单位不合法,就是通过后续处理设置为默认值 100MB:

// cache/util.go
// 用户使用不合法,打印日志并设置默认值
if num == 0 {
    log.Println("ParseSize 仅支持 B、KB、MB、GB、TB、PB")
    num = 100
    byteNum = num * MB
    unit = "MB"
}

​    最后由于我们需要返回的有两种形式,即字符串形式和数字形式,所以这里还需要拼接一下字符串形式。这里没有直接使用用户传入的值,是因为可能用户的输入有问题,然后我们采用的是默认值,故这里直接统一全部重新拼接:

// cache/util.go
sizeStr := strconv.FormatInt(num, 10) + unit

```

​    至此,ParseSize 函数,我们就实现完毕了,完整代码如下:

// ParseSize 单位统一,并且检查设置是否正确
func ParseSize(size string) (int64, string) {
    // 默认大小为 100

    // 利用正则表达式匹配一个或者多个数字
    re, _ := regexp.Compile("[0-9]+")
    // 获取单位:使用编译好的正则表达式 re,将 size 字符串中匹配的数字字符替换为空字符串
    unit := string(re.ReplaceAll([]byte(size), []byte("")))
    // 单位转换为大写
    unit = strings.ToUpper(unit)

    // 获取数字:将 size 字符串中的单位部分 unit 用空字符串替换,即可获取数字部分。最后再将数字转换为 int64 类型
    num, _ := strconv.ParseInt(strings.Replace(size, unit, "", 1), 10, 64)

    var byteNum int64 = 0
    // 1KB 100KB 1MB 2MB 1GB,单位统一为 byte
    switch unit {
    case "B":
        byteNum = num
    case "KB":
        byteNum = num * KB
    case "MB":
        byteNum = num * MB
    case "GB":
        byteNum = num * GB
    case "TB":
        byteNum = num * TB
    case "PB":
        byteNum = num * PB
    default: // 设置不合法,设置为 0,后续设置为 默认值
        num = 0
    }

    // 用户使用不合法,打印日志并设置默认值
    if num == 0 {
        log.Println("ParseSize 仅支持 B、KB、MB、GB、TB、PB")
        num = 100
        byteNum = num * MB
        unit = "MB"
    }

    sizeStr := strconv.FormatInt(num, 10) + unit
    return byteNum, sizeStr
}

 ​    然后我们的 SetMaxMemory 函数就简单了,如下:

// cache/memCache.go
func (mc *memCache) SetMaxMemory(size string) bool {
    mc.maxMemorySize, mc.maxMemorySizeStr = ParseSize(size)
    fmt.Println(mc.maxMemorySize, mc.maxMemorySizeStr)
    return true
}

​    然后我们可以运行 main.go 函数,打印一下检查是否有问题:

$ go run main.go
104857600 100MB

​    可以用计算器算一下,没有问题,我们的 SetMaxMemory 函数就完成了。

Set

​    然后是我们的 Set 方法,这个方法是用来将键值对存入我们的缓存系统中的。

​    首先,我们需要考虑用什么类型来存储键值对,很自然就可以想到用 Go 语言内置的字典,即 map 来实现。那新的问题又来了,那 map 的 key-value 分别用什么类型呢?key 的类型,毫无疑问肯定是 string 类型;value 的类型的话,这里如果也用一个单独的 interface{} 类型的话,可能也会存在一些问题,因为我们的 value 需要携带很多附加信息,比如 value 的值、过期时间、value 大小等,故这里的 value 需要用一个结构体去表示,故我们要先创建一个 memCacheValue 结构体:

// cahce.memCache.go
type memCacheValue struct {
    // value 值
    val interface{}
    // 过期时间
    expireTime time.Time
    // 有效时长
    expire time.Duration
    // value 大小。用于计算当前内存大小
    size int64
}

​    有了 memCacheValue,就可以在 memCache 中新增一个字段了:

// cahce.memCache.go
type memCache struct {
    // 最大内存大小
    maxMemorySize int64
    // 最大内存字符串表示
    maxMemorySizeStr string
    // 当前内存大小
    currMemorySize int64
    // 缓存键值对
    values map[string]*memCacheValue
}

​    由于这里使用了 map,故在初始化 memCache 实例的时候,需要进行内存的分配,所以我们要修改 NewMemCache 函数:

// cahce.memCache.go
func NewMemCache() Cache {
    mc := &memCache{
        values: make(map[string]*memCacheValue),
    }
    return mc
}

​    言归正传,继续分析我们的 Set 方法的实现,由于 Set 方法是写操作,Map 并非线程安全的,所以我们在进行写操作的时候需要进行加锁保护,故这里 memCache 结构中还需要加一个锁:

type memCache struct {
    ...
    // 读写锁
    locker sync.RWMutex
}

​    这里我们采用读写锁,这样可以利用 map 的读写机制:读操作兼容、写操作互斥,最大化提升读写 map 的性能。

​    因为我们的键可能存在过期时间,如果是重复 Set 一个已存在的值,就需要去重新计算更新对应的时间,会需要分情况讨论,比较复杂。所以,这里我们统一使用,先删除对应键值,再添加对应键值,写起来会比较方便,下面我们实现三个方法,以便我们调用:

// 判断是否存在对应的 value
func (mc *memCache) get(key string) (*memCacheValue, bool) {
    val, ok := mc.values[key]
    return val, ok
}

// 删除:当前内存大小更新、删除当前 key 值
func (mc *memCache) del(key string) {
    tmp, ok := mc.get(key)
    if ok && tmp != nil {
        mc.currMemorySize -= tmp.size
        delete(mc.values, key)
    }
}

// 添加:当前内存大小更新、删除当前 key 值
func (mc *memCache) add(key string, val *memCacheValue) {
    mc.values[key] = val
    mc.currMemorySize += val.size
}

​    上述三个方法比较简单,就不做过多赘述了。有了这三个函数,我们后面其他的方法实现起来,都会很简单。

​    然后我们的 Set 方法就可以写了:

func (mc *memCache) Set(key string, val interface{}, expire time.Duration) bool {
    // map 非线程安全需要加锁访问
    mc.locker.Lock()
    defer mc.locker.Unlock()
    // 确定一个 value 值
    v := &memCacheValue{
        val:        val,
        expireTime: time.Now().Add(expire),
        expire:     expire,
        size:       GetValSize(val),
    }
    // 为了简化代码复杂度,这里用 “删除再添加” 来代替 “更新” 操作
    if _, ok := mc.get(key); ok { // 存在则删除
        mc.del(key)
    }
    mc.add(key, v)

    // 新增后缓存是否超过最大内存:超过则直接删除刚刚添加的这个 key,并报 panic
    if mc.currMemorySize > mc.maxMemorySize {
        mc.del(key)
        // 这里可以自己完善一下,通过一些内存淘汰策略来选择删除一些 key,来判断是否还会超过最大内存
        log.Println(fmt.Sprintf("max memory size %s", mc.maxMemorySizeStr))
    }
    return false
}

1. 在开始操作之前,加写锁保护
2. 根据用户输入,构建对应的 value 值
3. 如果存在对应键值对,就先删除,然后再添加对应键值
4. 新增后判断是否超过内存,超过了的话,就直接删除刚刚添加的这个键

​    上述 Set 方法中还用到一个函数 GetValSize ,我们可以先不去实现这个函数具体逻辑,后面再回过头来看:

// cache/util.go
// GetValSize 计算 value 值大小
func GetValSize(val interface{}) int64 {
    return 0
}
Get

​    Get 方法,是根据用户输入的键,来获取对应的 value 值的。实现很简单,如下:

func (mc *memCache) Get(key string) (interface{}, bool) {
    mc.locker.RLock()
    defer mc.locker.RUnlock()

    // 拿到对应的值
    mcv, ok := mc.get(key)
    // 判断是否过期
    if ok {
        if mcv.expire != 0 && mcv.expireTime.Before(time.Now()) { // 过期时间早于当前时间,删除
            mc.del(key)
            return nil, false
        }
        return mcv.val, ok
    }
    return nil, false
}

1. 加读锁保护
2. 先通过 get 方法拿到对应的值
3. 如果存在该键,且该值没有过期或者没有过期时间,则返回该值
4. 否则返回 nil,并删除该过期键

Del

​    Del 方法,是用于删除对应键值对的。直接加写锁操作,并调用先前实现的 del 函数即可:

func (mc *memCache) Del(key string) bool {
    mc.locker.Lock()
    defer mc.locker.Unlock()
    mc.del(key)
    return true
}

1. 加写锁保护
2. 直接调用 del 函数进行删除对应键值对即可

Exists

​    Exists 方法,用于判断某个键是否存在于我们的缓存系统。实现也非常简单:

func (mc *memCache) Exists(key string) bool {
    mc.locker.RLock()
    defer mc.locker.RUnlock()
    _, ok := mc.values[key]
    return ok
}

1. 加读锁保护
2. 直接获取对应键值对,以此判断是否存在该键

Flush

​    Flush 方法,是在整个缓存系统的缓存数据不需要使用之后,用来清空所有的缓存时使用的。这里我们利用 Go 语言的垃圾回收机制,直接将整个 map 置空,Go 语言的垃圾回收机制会直接将没有使用的内存进行回收:

func (mc *memCache) Flush() bool {
    mc.locker.Lock()
    defer mc.locker.Unlock()
    // 直接将整个 map 置空,go 的垃圾回收机制会自行将没有使用的内存进行回收
    mc.values = make(map[string]*memCacheValue, 0)
    mc.currMemorySize = 0

    return true
}

1. 加写锁保护
2. 将整个 map 置空,并将当前使用内存大小清空

Keys

​    Keys 方法,用于获取缓存中 key 的数量。直接用 len() 函数获取即可:

func (mc *memCache) Keys() int64 {
    mc.locker.RLock()
    defer mc.locker.RUnlock()
    return int64(len(mc.values))
}

1. 加读锁保护
2. 利用 len() 函数直接获取

​    

​    现在我们再来看看这个 GetValSize 函数,这里有两种思路实现:

1. 利用反射包 `unsafe.Sizeof(val)` 来获取对应的值的大小
2. 野路子:利用 json 包,将 val 序列化为字节数组,然后求字节数组的长度,就知道该值占用了多少字节了

​    通过测试,可以发现第一种方法是不可靠的,因为`unsafe.Sizeof()` 方法只是算出对应类型的字节大小,而不是你所存储的内容的具体大小。于是我们这里采用第二种方法来实现:

// cache/util.go
// GetValSize 计算 value 值大小
func GetValSize(val interface{}) int64 {
    // 野路子:利用 json 包,将 val 序列化为字节数组,然后求字节数组的长度,就知道占用了多少字节了
    bytes, _ := json.Marshal(val)
    size := int64(len(bytes))
    return size
}

​    至此,我们的基础功能,就差不多实现了。大家可以通过在 main 函数打印对应的信息来检查。在这里,我就不再带着大家检查了。

实现代理层

​    首先我们得明白,为什么要再加一层代理层?在这里,题目给的接口是包含过期时间的,但是我们的使用示例却没有使用过期时间,这就是说明需要加一层代理层来进行封装。

​    添加代理层还有一些好处,比如:

- 安全性:它可以过滤和阻止对系统的未经授权的访问,通过身份验证和授权机制确保只有合法的用户或服务可以访问底层的资源。这有助于防范潜在的安全威胁。
- 性能优化:代理层可以缓存某些请求的结果,以避免重复计算或数据库查询。此外,代理层还可以对请求进行负载均衡,确保各个后端服务得到合理的分配,以提高整体性能。
- 抽象底层实现: 代理层可以用于隐藏底层实现的复杂性,提供简化的接口给上层系统。这有助于实现系统的模块化和降低耦合度,使得系统更容易维护和扩展。

​    下面我们来看看怎么实现代理层:

​    首先,在项目根目录创建一个文件夹 cache-server ,并在该目录下创建一个 cache.go 文件,完整代码如下:

package cache_server

import (
    "main/cache"
    "time"
)

// 代理层/适配层
type cacheServer struct {
    memCache cache.Cache
}

func NewMemCache() *cacheServer {
    return &cacheServer{
        memCache: cache.NewMemCache(),
    }
}

// SetMaxMemory size : 1KB 100KB 1MB 2MB 1GB
func (cs *cacheServer) SetMaxMemory(size string) bool {
    return cs.memCache.SetMaxMemory(size)
}

// Set 将 value 写入缓存
// 代理层:将 有效时长参数设置为可有可无
func (cs *cacheServer) Set(key string, val interface{}, expire ...time.Duration) bool {
    // 默认值为 0 秒
    expireTs := time.Second * 0
    if len(expire) > 0 {
        expireTs = expire[0]
    }
    return cs.memCache.Set(key, val, expireTs)
}

// Get 根据 key 值获取 value
func (cs *cacheServer) Get(key string) (interface{}, bool) {
    return cs.memCache.Get(key)
}

// Del 删除 key 值
func (cs *cacheServer) Del(key string) bool {
    return cs.memCache.Del(key)
}

// Exists 判断 key 是否存在
func (cs *cacheServer) Exists(key string) bool {
    return cs.memCache.Exists(key)
}

// Flush 清空所有 key
func (cs *cacheServer) Flush() bool {
    return cs.memCache.Flush()
}

// Keys 获取缓存中所有 key 的数量
func (cs *cacheServer) Keys() int64 {
    return cs.memCache.Keys()
}

1. 首先我们仍然需要先将 cache 接口封装起来,并写一个构造函数返回一个实例
2. 除了 Set 方法外,其他方法直接调用实现即可
3. 在 Set 方法中,将过期时间参数设置为可选参数,即 `expire ...time.Duration`,然后通过判断是否传入该参数来构建新的参数 `expireTs` 去调用实现好的方法

​    实现完成后,我们就可以将 main 函数的代码修改一下,调用代理层提供的方法来进行使用:

package main

import (
    cache_server "main/cache-server"
)

func main() {
    cache := cache_server.NewMemCache()
    cache.SetMaxMemory("100MB")
    cache.Set("int", 1)
    cache.Set("bool", false)
    cache.Set("data", map[string]interface{}{"a": 1})
    cache.Get("int")
    cache.Del("int")
    cache.Flush()
    cache.Keys()
}

​    这样,即使我们不使用带过期时间的 Set 方法,也不会报错了。

加分项

​    最后我们再来看看我们的加分项:

轮询检查删除过期键

​    我们可以在创建缓存系统实例的时候,同时开启我们的 ” 轮询检查删除过期键 “ 功能。

func NewMemCache() Cache {
    mc := &memCache{
        values:                       make(map[string]*memCacheValue),
        cleanExpiredItemTimeInterval: time.Second, // 定期清理缓存
    }
    // 轮询检查删除过期键
    go mc.cleanExpiredItem()
    return mc
}

​    这里需要新添加一个字段 `cleanExpiredItemTimeInterval` 表示清理周期,还需要写一个轮询的函数,如下:

type memCache struct {
    ...
    // 清楚过期缓存时间间隔
    cleanExpiredItemTimeInterval time.Duration
}

​    下面是轮询的函数:

// 轮询清空过期 key
func (mc *memCache) cleanExpiredItem() {
    // 设置一个定时触发器:定时向 Ticker.C 中发送一个消息,即触发了一次
    timeTicker := time.NewTicker(mc.cleanExpiredItemTimeInterval)
    defer timeTicker.Stop()
    for {
        select {
        case <-timeTicker.C: // 每个周期做一个缓存清理
            // 遍历所有缓存的键值对,将有过期时间且过期的键删除掉
            for key, item := range mc.values {
                if item.expire != 0 && time.Now().After(item.expireTime) {
                    mc.locker.Lock()
                    mc.del(key)
                    mc.locker.Unlock()
                }
            }
        }
    }
}

1. 采用 `time.NewTicker`,定义一个制定周期的定时器
2. 由于需要不断轮询,故需要放在 for 循环中
3. 配合 select 实现一个阻塞式的轮询检查并删除过期键的操作操作

单元测试

​    单元测试是一种用于验证程序各个独立单元是否能按照预期工作的测试方法。Go语言的测试工具内置于语言本身,通过 `testing` 包提供了一套简单而有效的测试框架。平时不论是学习、还是工作,都应该养成写单元测试的习惯。

​    我们在 cache 包下,创建一个 memCache_test.go 文件,并在里面写我们测试内容:

package cache

import (
    "testing"
    "time"
)

func TestCacheOP(t *testing.T) {
    testData := []struct {
        key    string
        val    interface{}
        expire time.Duration
    }{
        {"baer", 678, time.Second * 10},
        {"hrws", false, time.Second * 11},
        {"gddfas", true, time.Second * 12},
        {"rwe", map[string]interface{}{"a": 3, "b": false}, time.Second * 13},
        {"rqew", "fsdfas", time.Second * 14},
        {"fsdew", "这里是字符串这里是字符串这里是字符串", time.Second * 15},
    }

    c := NewMemCache()
    c.SetMaxMemory("10MB")
    // 测试 set 和 get
    for _, item := range testData {
        c.Set(item.key, item.val, item.expire)
        val, ok := c.Get(item.key)
        if !ok {
            t.Error("缓存取值失败")
        }
        if item.key != "rwe" && val != item.val {
            t.Error("缓存取值数据与预期不一致")
        }
        _, ok1 := val.(map[string]interface{})
        if item.key == "rwe" && !ok1 {
            t.Error("缓存取值数据与预期不一致")
        }
    }
    // 测试 Keys()
    if int64(len(testData)) != c.Keys() {
        t.Error("缓存数量不一致")
    }
    // 测试 Del()
    c.Del(testData[0].key)
    c.Del(testData[1].key)

    if int64(len(testData)) != c.Keys()+2 {
        t.Error("缓存数量不一致")
    }

    // 测试过期时间
    time.Sleep(time.Second * 16)

    if c.Keys() != 0 {
        t.Error("缓存清空失败")
    }
}

先用匿名结构体,构建需要用到的各类测试数据

然后对 Set、Get、Del 等方法进行调用,然后对比结果

小结

​    这篇文章,通过一个面试题,从题目到各种坑点的分析,带大家了解并实现了一个简易版的 内存缓存系统  。相信大家在看完后肯定会收货满满。

完整代码

这是项目的目录结构:

下面会给出各个文件的内容:

main.go
package main

import (
    "fmt"
    cacheserver "main/cache-server"
    "time"
)

func main() {
    cache := cache_server.NewMemCache()
    cache.SetMaxMemory("100MB")
    cache.Set("int", 1)
    cache.Set("bool", false)
    cache.Set("data", map[string]interface{}{"a": 1})
    cache.Get("int")
    cache.Del("int")
    cache.Flush()
    cache.Keys()
}
cache/cache.go
package cache

import "time"

type Cache interface {
    SetMaxMemory(size string) bool
    Set(key string, val interface{}, expire time.Duration) bool
    Get(key string) (interface{}, bool)
    Del(key string) bool
    Exists(key string) bool
    Flush() bool
    Keys() int64
}
cache/memCache.go
package cache

import (
    "fmt"
    "log"
    "sync"
    "time"
)

type memCache struct {
    maxMemorySize int64
    maxMemorySizeStr string
    currMemorySize int64
    values map[string]*memCacheValue
    locker sync.RWMutex
    cleanExpiredItemTimeInterval time.Duration
}

type memCacheValue struct {
    val interface{}
    expireTime time.Time
    expire time.Duration
    size int64
}

func NewMemCache() Cache {
    mc := &memCache{
        values:                       make(map[string]*memCacheValue),
        cleanExpiredItemTimeInterval: time.Second, 
    }
    go mc.cleanExpiredItem()
    return mc
}

func (mc *memCache) SetMaxMemory(size string) bool {
    mc.maxMemorySize, mc.maxMemorySizeStr = ParseSize(size)
    return true
}

func (mc *memCache) Set(key string, val interface{}, expire time.Duration) bool {
    mc.locker.Lock()
    defer mc.locker.Unlock()
    v := &memCacheValue{
        val:        val,
        expireTime: time.Now().Add(expire),
        expire:     expire,
        size:       GetValSize(val),
    }
    if _, ok := mc.get(key); ok {
        mc.del(key)
    }
    mc.add(key, v)

    if mc.currMemorySize > mc.maxMemorySize {
        mc.del(key)
        log.Println(fmt.Sprintf("max memory size %s", mc.maxMemorySizeStr))
    }
    return false
}

func (mc *memCache) get(key string) (*memCacheValue, bool) {
    val, ok := mc.values[key]
    return val, ok
}

func (mc *memCache) del(key string) {
    tmp, ok := mc.get(key)
    if ok && tmp != nil {
        mc.currMemorySize -= tmp.size
        delete(mc.values, key)
    }
}

func (mc *memCache) add(key string, val *memCacheValue) {
    mc.values[key] = val
    mc.currMemorySize += val.size
}

func (mc *memCache) Get(key string) (interface{}, bool) {
    mc.locker.RLock()
    defer mc.locker.RUnlock()

    mcv, ok := mc.get(key)
    if ok {
        if mcv.expire != 0 && mcv.expireTime.Before(time.Now()) {
            mc.del(key)
            return nil, false
        }
        return mcv.val, ok
    }
    return nil, false
}

func (mc *memCache) Del(key string) bool {
    mc.locker.Lock()
    defer mc.locker.Unlock()
    mc.del(key)
    return true
}

func (mc *memCache) Exists(key string) bool {
    mc.locker.RLock()
    defer mc.locker.RUnlock()
    _, ok := mc.values[key]
    return ok
}

func (mc *memCache) Flush() bool {
    mc.locker.Lock()
    defer mc.locker.Unlock()
    mc.values = make(map[string]*memCacheValue, 0)
    mc.currMemorySize = 0

    return true
}

func (mc *memCache) Keys() int64 {
    mc.locker.RLock()
    defer mc.locker.RUnlock()
    return int64(len(mc.values))
}

func (mc *memCache) cleanExpiredItem() {
    timeTicker := time.NewTicker(mc.cleanExpiredItemTimeInterval)
    defer timeTicker.Stop()
    for {
        select {
        case <-timeTicker.C: 
            for key, item := range mc.values {
                if item.expire != 0 && time.Now().After(item.expireTime) {
                    mc.locker.Lock()
                    mc.del(key)
                    mc.locker.Unlock()
                }
            }
        }
    }
}
cache/memCache_test.go
package cache

import (
    "testing"
    "time"
)

func TestCacheOP(t *testing.T) {
    testData := []struct {
        key    string
        val    interface{}
        expire time.Duration
    }{
        {"baer", 678, time.Second * 10},
        {"hrws", false, time.Second * 11},
        {"gddfas", true, time.Second * 12},
        {"rwe", map[string]interface{}{"a": 3, "b": false}, time.Second * 13},
        {"rqew", "fsdfas", time.Second * 14},
        {"fsdew", "这里是字符串这里是字符串这里是字符串", time.Second * 15},
    }

    c := NewMemCache()
    c.SetMaxMemory("10MB")
    
    for _, item := range testData {
        c.Set(item.key, item.val, item.expire)
        val, ok := c.Get(item.key)
        if !ok {
            t.Error("缓存取值失败")
        }
        if item.key != "rwe" && val != item.val {
            t.Error("缓存取值数据与预期不一致")
        }
        _, ok1 := val.(map[string]interface{})
        if item.key == "rwe" && !ok1 {
            t.Error("缓存取值数据与预期不一致")
        }
    }
    
    if int64(len(testData)) != c.Keys() {
        t.Error("缓存数量不一致")
    }
    
    c.Del(testData[0].key)
    c.Del(testData[1].key)

    if int64(len(testData)) != c.Keys()+2 {
        t.Error("缓存数量不一致")
    }

    time.Sleep(time.Second * 16)

    if c.Keys() != 0 {
        t.Error("缓存清空失败")
    }
}
cache/util.go
package cache

import (
    "encoding/json"
    "log"
    "regexp"
    "strconv"
    "strings"
)

const (
    B = 1 << (iota * 10)
    KB
    MB
    GB
    TB
    PB
)

func ParseSize(size string) (int64, string) {
    re, _ := regexp.Compile("[0-9]+")
    
    unit := string(re.ReplaceAll([]byte(size), []byte("")))
    
    unit = strings.ToUpper(unit)

    num, _ := strconv.ParseInt(strings.Replace(size, unit, "", 1), 10, 64)

    var byteNum int64 = 0
    switch unit {
    case "B":
        byteNum = num
    case "KB":
        byteNum = num * KB
    case "MB":
        byteNum = num * MB
    case "GB":
        byteNum = num * GB
    case "TB":
        byteNum = num * TB
    case "PB":
        byteNum = num * PB
    default: 
        num = 0
    }

    if num == 0 {
        log.Println("ParseSize 仅支持 B、KB、MB、GB、TB、PB")
        num = 100
        byteNum = num * MB
        unit = "MB"
    }

    sizeStr := strconv.FormatInt(num, 10) + unit
    return byteNum, sizeStr
}

func GetValSize(val interface{}) int64 {
    bytes, _ := json.Marshal(val)
    size := int64(len(bytes))
    return size
}
cache-server/cache.go
package cache_server

import (
    "main/cache"
    "time"
)

type cacheServer struct {
    memCache cache.Cache
}

func NewMemCache() *cacheServer {
    return &cacheServer{
        memCache: cache.NewMemCache(),
    }
}

func (cs *cacheServer) SetMaxMemory(size string) bool {
    return cs.memCache.SetMaxMemory(size)
}

func (cs *cacheServer) Set(key string, val interface{}, expire ...time.Duration) bool {
    expireTs := time.Second * 0
    if len(expire) > 0 {
        expireTs = expire[0]
    }
    return cs.memCache.Set(key, val, expireTs)
}

func (cs *cacheServer) Get(key string) (interface{}, bool) {
    return cs.memCache.Get(key)
}

func (cs *cacheServer) Del(key string) bool {
    return cs.memCache.Del(key)
}

func (cs *cacheServer) Exists(key string) bool {
    return cs.memCache.Exists(key)
}

func (cs *cacheServer) Flush() bool {
    return cs.memCache.Flush()
}

func (cs *cacheServer) Keys() int64 {
    return cs.memCache.Keys()
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/130626.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【poi导出excel模板——通过建造者模式+策略模式+函数式接口实现】

poi导出excel模板——通过建造者模式策略模式函数式接口实现 poi导出excel示例优化思路代码实现补充建造者模式策略模式 poi导出excel示例 首先我们现看一下poi如何导出excel&#xff0c;这里举个例子&#xff1a;目前想要导出一个Map<sex,List>信息&#xff0c;sex作为…

使用Dockerfile依赖maven基础镜像部署springboot的程序案例

1、准备springboot Demo代码 就一个controller层代码&#xff0c;返回当前时间及hello world 2、项目根目录下&#xff0c;新建DockerFile文件 注意&#xff0c;等本地配置完毕后&#xff0c;Dockerfile文件需要与项目helloworld同级&#xff0c;这里先放项目里面 3、docker …

【MATLAB源码-第73期】基于matlab的OFDM-IM索引调制系统不同子载波数目误码率对比,对比OFDM系统。

操作环境&#xff1a; MATLAB 2022a 1、算法描述 OFDM-IM索引调制技术是一种新型的无线通信技术&#xff0c;它将正交频分复用&#xff08;OFDM&#xff09;和索引调制&#xff08;IM&#xff09;相结合&#xff0c;以提高频谱效率和系统容量。OFDM-IM索引调制技术的基本思想…

Flink SQL自定义标量函数(Scalar Function)

使用场景&#xff1a; 标量函数即 UDF&#xff0c;⽤于进⼀条数据出⼀条数据的场景。 开发流程&#xff1a; 实现 org.apache.flink.table.functions.ScalarFunction 接⼝实现⼀个或者多个⾃定义的 eval 函数&#xff0c;名称必须叫做 eval&#xff0c;eval ⽅法签名必须是 p…

快速入门安装及使用git与svn的区别常用命令

一、导言 1、什么是svn&#xff1f; SVN是Subversion的简称&#xff0c;是一个集中式版本控制系统。与Git不同&#xff0c;SVN没有分布式的特性。在SVN中&#xff0c;项目的代码仓库位于服务器上&#xff0c;团队成员通过向服务器提交和获取代码来实现版本控制。SVN记录了每个…

Hbuilder打包项目为h5

Hbuilder打包项目为h5 manifest.json 配置 修改 web 配置下的 页面标题、路由模式、运行的基础路径 发行 H5 发行 填入网站标题和网站域名 编译 编译完成之后存放在 unpackage/dist/build/h5 目录下

Day26力扣打卡

打卡记录 搜索旋转排序数组&#xff08;二分&#xff09; 链接 class Solution {int findMin(vector<int> &nums) {int left -1, right nums.size() - 1; // 开区间 (-1, n-1)while (left 1 < right) { // 开区间不为空int mid left (right - left) / 2;if…

医学图像 ABIDE 等数据集 .nii.gz Python格式化显示

nii.gz 文件 .nii.gz 文件通常是医学影像数据的一种常见格式&#xff0c;比如神经影像&#xff08;如脑部MRI&#xff09;。这种文件格式通常是经过gzip压缩的NIfTI格式&#xff08;Neuroimaging Informatics Technology Initiative&#xff09;。 要在Python中查看.nii.gz文…

设备零部件更换ar远程指导系统加强培训效果

随着科技的发展&#xff0c;AR技术已经成为了一种广泛应用的新型技术。AR远程指导系统作为AR技术的一种应用&#xff0c;具有非常广泛的应用前景。 一、应用场景 气象监测AR教学软件适用于多个领域&#xff0c;包括气象、环境、地理等。在教学过程中&#xff0c;软件可以帮助学…

黑客(网络安全)技术——高效自学1.0

前言 前几天发布了一篇 网络安全&#xff08;黑客&#xff09;自学 没想到收到了许多人的私信想要学习网安黑客技术&#xff01;却不知道从哪里开始学起&#xff01;怎么学 今天给大家分享一下&#xff0c;很多人上来就说想学习黑客&#xff0c;但是连方向都没搞清楚就开始学习…

Paimon 与 Spark 的集成(一)

Paimon Apache Paimon (incubating) 是一项流式数据湖存储技术&#xff0c;可以为用户提供高吞吐、低延迟的数据摄入、流式订阅以及实时查询能力。Paimon 采用开放的数据格式和技术理念&#xff0c;可以与 ApacheFlink / Spark / Trino 等诸多业界主流计算引擎进行对接&#xf…

听GPT 讲Rust源代码--library/core/src(2)

题图来自 5 Ways Rust Programming Language Is Used[1] File: rust/library/core/src/iter/adapters/by_ref_sized.rs 在Rust的源代码中&#xff0c;rust/library/core/src/iter/adapters/by_ref_sized.rs 文件实现了 ByRefSized 适配器&#xff0c;该适配器用于创建一个可以以…

在Node.js中,什么是事件发射器(EventEmitter)?

聚沙成塔每天进步一点点 ⭐ 专栏简介 前端入门之旅&#xff1a;探索Web开发的奇妙世界 欢迎来到前端入门之旅&#xff01;感兴趣的可以订阅本专栏哦&#xff01;这个专栏是为那些对Web开发感兴趣、刚刚踏入前端领域的朋友们量身打造的。无论你是完全的新手还是有一些基础的开发…

全新Inner-IoU损失函数!!!通过辅助边界框计算IoU有效提升检测效果

摘要 1 简介 2 方法 2.1 边界框回归模式分析 2.2 Inner-IoU 损失 3 实验 3.1 模拟实验 3.2 对比实验 3.2.1 PASCAL VOC上的YOLOv7 3.2.2 YOLOv5 在 AI-TOD 上 4. 参考 摘要 随着检测器的快速发展&#xff0c;边界框回归&#xff08;BBR&#xff09;损失函数不断进…

11月份 四川汽车托运报价已经上线

中国人不骗中国人!! 国庆小长假的高峰期过后 放假综合症的你还没痊愈吧 今天给大家整理了9条最新线路 广州到四川的托运单价便宜到&#x1f4a5; 核算下来不过几毛钱&#x1f4b0; 相比起自驾的漫长和疲惫&#x1f697; 托运不得不说真的很省事 - 赠送保险 很多客户第一次运车 …

多目标优化框架

随着模型越来越复杂&#xff0c;优化目标越来越多&#xff0c;传统算法都慢慢地无法胜任复杂优化任务&#xff0c;更为智能的优化方法也就应运而生了。其中有一类是进化优化算法&#xff0c;这类算法的思想来源是自然界的“优胜劣汰”法则&#xff0c;通过不停地保留好的个体最…

艾默生Emerson EDI需求分析

艾默生Emerson是一家全球领先的工程技术和解决方案提供商。该公司总部位于美国&#xff0c;成立于1890年&#xff0c;经过多年的发展&#xff0c;已经发展成为一个多元化的跨国企业&#xff0c;业务遍及工业、商业和消费者市场。艾默生提供各种产品和服务&#xff0c;包括自动化…

CSS3 过度效果、动画、多列

一、CSS3过度&#xff1a; CSS3过渡是元素从一种样式逐渐改变为另一种的效果。要实现这一点&#xff0c;必须规定两相内容&#xff1a;指定要添加效果的CSS属性&#xff1b;指定效果的持续时间。如果为指定持续时间&#xff0c;transition将没有任何效果。 <style> div…

Python 的 datetime 模块

目录 简介 一、date类 &#xff08;一&#xff09;date 类属性 &#xff08;二&#xff09;date 类方法 &#xff08;三&#xff09;实例属性 &#xff08;四&#xff09;实例的方法 二、time类 &#xff08;一&#xff09;time 类属性 &#xff08;二&#xff09;tim…

python调用chrome实现网页自动操作

一. 内容简介 python调用chrome实现网页自动操作。 二. 软件环境 2.1vsCode 2.2Anaconda version: conda 22.9.0 2.3代码 链接&#xff1a; 三.主要流程 3.1 下载驱动和插件 调用谷歌浏览器&#xff0c;需要下载浏览器驱动&#xff08;https://registry.npmmirror.co…