原创分享 在 Go 项目中基于本地内存缓存的实现及应用

yudotyang · 2021年11月01日 · 最后由 1519868095 回复于 2021年11月29日 · 698 次阅读
本帖已被设为精华帖!

大家好,我是 Go 学堂的渔夫子。今天给大家介绍一下在 Go 项目中在数据量小、读取频繁的场景中如何实现基于本地内存缓存的方法以提高系统性能。

对于缓存,大家都不陌生。百度百科的定义是这样的:

缓存是指可以进行高速数据交换的存储器,它先于内存与 CPU 交换数据,因此速率很快。

由此可知,缓存是用来提高数据交换速度的。我们今天要讲的缓存不是 CPU 中的缓存,而是在应用程序中对数据库的缓存。应用程序先于数据库,从缓存中读取数据,以降低数据库的压力,提高应用程序的读取性能。

在实际项目中,相信大家也都遇到过类似的情景:数据量小,但访问又较频繁(例如国家标准行政区域数据),想将其完全存放于本地内存中。这样就可以避免直接访问 mysql 或 redis,减少网络传输,提高访问速度。那具体应该怎么实现呢?

本文就介绍一种 Go 项目中经常使用到的方法:将数据从数据库中加载到本地文件,然后再将文件中的数据加载到内存中,内存中的数据直接供应用程序使用。如下图所示:

本文会忽略数据库到本地文件的过程,因为这个环节就是一个文件上传和下载到本地的过程。所以我们会重点讲解如何从本地文件加载数据到内存中这个环节。

01 目标

在 Go 语言的项目中,将本地文件的数据加载到应用程序的内存中,以供应用程序直接使用。

我们再将目标拆解成两个目标:

1、程序启动时,将本地文件的数据初始化到内存中,即冷启动

2、程序运行期间,本地文件有更新时,将数据更新到内存中。

02 代码实现

本文主要是目的就是给大家讲解目标的实现,所以不会带大家一步步分析,而是通过讲解已实现的代码来给大家提供一种参考实现。

所以,我们先给出我们设计的类图:

从类图中可知,有两个主要的结构体:FileDoubleBuffer 和 LocalFileLoader。下面我们一一讲解这两个结构体的属性和方法实现。

2.1 场景假设

我们以城市的天气状况为示例,将每个城市的实时温度和风力以 json 格式存储在文件中,当城市的温度或风力有变化时,再更新该文件。如下:

{
    "beijing": {
        "temperature": 23,
        "wind": 3
    },
    "tianjin": {
        "temperature": 20,
        "wind": 2
    },
    "shanghai": {
        "temperature": 20,
        "wind": 20
    },
    "chongqing": {
        "temperature": 30,
        "wind": 10
    }
}

2.2 main 的调用

这里,先给出 main 函数的调用示例,根据 main 函数中的实现,我们一步步看图中两个主要结构体的实现,代码如下:

//第一步,定义装载文件中数据的结构体
type WeatherContainer struct {
    Weathers map[string]*Weather //每个城市对应的实况天气
}
//文件数据中每个城市的天气状况
type Weather struct {
    Temperature int //当前气温 `json:"temperature"`
    Wind        int //当前风力 `json:"wind"`
}
func main() {
    pwd, _ := os.Getwd()
    //加载的文件路径
    filename := pwd + "/cache/cache.json"
    //初始化本地文件加载器
    localFileLoader := NewLocalFileLoader(filename)
    //初始化文件缓冲实例,将localFileLoader作为底层的文件缓冲
    fileDoubleBuffer := NewFileDoubleBuffer(localFileLoader)

    // 开始将文件中的内容加载到缓冲变量中,本质上就是通过load和reload加载文件数据    
    fileDoubleBuffer.StartFileBuffer()

    //获取数据
    weathersConfig := fileDoubleBuffer.Data().(*WeatherContainer)
    fmt.Println("weathers:", weathersConfig.Weathers["beijing"])

    blockCh := make(chan int)
    //该通道用于阻塞进程不结束,这样reload的协程就可以执行了
    <-blockCh
}

2.3 FileDoubleBuffer 结构体及实现

该结构体的作用主要是面向应用程序(我们这里是 main 函数),供应用程序直接从内存即 bufferData 中获取数据的。该结构体的定义如下:

// main应用主要面向该结构体获取数据
type FileDoubleBuffer struct {
    Loader     *LocalFileLoader
    bufferData []interface{}
    curIndex   int32
    mutex      sync.Mutex
}

首先看该结构体的属性:

Loader:是一个 LocalFileLoader 类型(后面会定义该结构体),用于从具体的文件中加载数据到 bufferData 中。

bufferData 切片:接收文件中数据的变量。一方面会将文件中的数据加载到该变量中。另一方面,应用程序直接从该变量中获取想要的数据信息,而非文件或数据库。该变量的数据类型是 interface{},说明可以加载任何类型的数据结构。另外,我们注意该变量是一个切片,该切片只有 2 个元素,两个元素具有相同的数据结构,结合 curIndex 属性使用。

curIndex:该属性是指定当前 bufferData 正在使用哪个索引中的数据,该属性的值在 0 和 1 之间循环,用于新老数据的切换。例如,当前对外使用的是 curIndex=1 这个索引元素的数据,当文件中有新数据时,先将文件的数据加载到索引 0 这个元素中,当将文件的数据完全加载完后,再将 curIndex 的值指向 0。这样,当文件中有新数据进行刷新内存中的数据时,不会影响应用程序对老数据的使用。

再来看 FileDoubleBuffer 中的函数:

Data() 函数

应用程序通过该函数来获取 FileDoubleBuffer 中的 dataBuffer 数据。具体实现如下:

func (buffer *FileDoubleBuffer) Data() interface{} {
    // bufferData实际上存储了两个相同结构的元素,用于切换新老数据
    index := atomic.LoadInt32(&buffer.curIndex)
    return buffer.bufferData[index]
}

load 函数

该函数是用于加载文件中的数据到 bufferData 中。代码实现如下:

func (buffer *FileDoubleBuffer) load() {
  buffer.mutex.Lock()
  defer buffer.mutex.Unlock()
  //判断当前使用的是bufferData数组哪个元素
  // 因bufferData中只有两个元素,所以要么是0,要么是1
  curIndex := 1 - atomic.LoadInt32(&buffer.curIndex)

  err := buffer.Loader.Load(buffer.bufferData[curIndex])
  if err == nil {
    atomic.StoreInt32(&buffer.curIndex, curIndex)
  }
}

reload 函数

用于从文件中加载新的数据到 bufferData 中。实际上是一个 for 循环,每隔一定的时间执行一次 load 函数,代码如下:

func (buffer *FileDoubleBuffer) reload() {
  for {
    time.Sleep(time.Duration(5) * time.Second)
    fmt.Println("开始加载...")
    buffer.load()
  }
}

StartFileBuffer 函数

该函数的作用是启动数据的加载和更新,代码如下:

func (buffer *FileDoubleBuffer) StartFileBuffer() {
  buffer.load()
  go buffer.reload()
}

NewFileDoubleBuffer(loader *LocalFileLoader) *FileDoubleBuffer 函数

该函数的作用是初始化 FileDoubleBuffer 实例,代码如下:

func NewFileDoubleBuffer(loader *LocalFileLoader) *FileDoubleBuffer {
  buffer := &FileDoubleBuffer{
    Loader:   loader,
    curIndex: 0,
  }

  //这里分配内存空间,以便将文件中的值加载到该变量中,供应用程序使用
  buffer.bufferData = append(buffer.bufferData, loader.Alloc(), loader.Alloc())
  return buffer
}

2.4 LocalFileLoader 结构体及实现 由于我们是将数据先从数据库加载到本地文件上,然后再将文件的数据加载到内存缓冲区中,故有了 LocalFileLoader 结构体。该结构体的作用是执行具体的文件数据加载和检测文件更新的任务。LocalFileLoader 的定义如下:

type LocalFileLoader struct {
  filename       string //需要加载的文件,完整路径
  lastModifyTime int64  //文件最近一次的修改时间
}

首先来看该结构体的属性:

filename:指定具体的文件名,说明从该文件中加载数据

modifyTime:最后一次加载文件的时间。如果文件的更新时间大于该时间,则说明文件有更新

再来看 LocalFileLoader 中的函数:

Load(filename string, i interface) 函数

该函数用于将 filename 文件中的数据加载到变量 i 中。该变量 i 实际上是从 FileDoubleBuffer 中传进来的 bufferData 中的元素,代码如下:

// 这里i变量实际上是从FileDoubleBuffer结构的load方法中传入的dataBuffer中的一个元素
func (loader *LocalFileLoader) Load(i interface{}) error {
    // WeatherContainer结构体是依据文件中具体存储的数据定义的,后面会讲到
    weatherContainer := i.(*WeatherContainer)
    fileHandler, _ := os.Open(loader.filename)
    defer fileHandler.Close()
    body, _ := ioutil.ReadAll(fileHandler)
    _ := json.Unmarshal(body, &weatherContainer.Weathers)
    // 这里我们省略了那些err的判断
    return nil
}

DetectNewFile() 函数

该函数用于检测 filename 文件是否有更新,如果文件的修改时间大于 modifyTime,则 FileDoubleBuffer 会将新的数据加载到 dataBuffer 中。代码如下:

// 该函数检查文件是否有更新,如果有更新 则返回true,否则返回false
func (loader *LocalFileLoader) DetectNewFile() bool {
    fileInfo, _ := os.Stat(loader.filename)
    //文件的修改时间比上次修改时间大,说明文件有更新
    if fileInfo.ModTime().Unix() > loader.lastModifyTime {
        loader.lastModifyTime = fileInfo.ModTime().Unix()
        return true
    }
    return false
}

*Alloc() interface{} *

用于分配具体的变量,以供装载文件中的数据。这里分配的变量最终会存储到 FileDoubleBuffer 中的 dataBuffer 数据中。代码如下:

// 分配具体的变量,来承载文件中的具体内容,变量结构体需要和文件中的结构体保持一致
func (loader *LocalFileLoader) Alloc() interface{} {
    return &WeatherContainer{
        Weathers: make(map[string]*Weather),
    }
}

同样需要一个初始化 LocalFileLoader 实例的函数:

//指定需要加载的文件路径path
func NewLocalFileLoader(path string) *LocalFileLoader {
    return &LocalFileLoader{
        filename: path,
    }
}

总结

这种方式一般适用于数据量较小、频繁读的场景。在文章开始的图中我们可以看到,因为是服务器往往是集群,所以每台机器上的文件内容可能会有短暂的差异,所以该实现也不适用于对数据具有强一致要求的场景中。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 11月02日 02:37

@yudotyang 可以给 GoCN 公众号开一下白名单吗?你的一些文章都不错,GoCN 公众号可以转发宣传一下,公众号 id:golangchina

astaxie 回复

已添加。多谢认可。能得到社区的支持 真是非常荣幸,我也会持续在社区输出有质量的文章的哈

yulibaozi GoCN 每日新闻 (2021-11-02) 中提及了此贴 11月02日 09:42
yudotyang 回复

今天我们小编发好像没有开白名单,转不了

astaxie 回复

确认了下开通了。方便的话 可加我微信 yudotyang ,这个是公众号所在的文章:https://mp.weixin.qq.com/s/XMUiKHIFiTPTOVfLyXX4hw

astaxie 回复

应该是设置问题,已重新配置。

yudotyang 回复

嗯,可以了,今天已经发了

我来捉个虫子😀 由于 load() 和 data() 两者并无互斥。考虑如下访问步骤:

  1. data() 拿到 curIndex 0,
  2. load() 拿到 1-curIndex = 1
  3. load() 加载数据,这里没有问题,设置 curIndex = 1
  4. 过了几秒钟 reload()->load() 执行,拿到 1-curIndex = 0, 此时 [0] 里的 map 执行 unmarshal,糟糕,把第 1 步中 data() 拿到的 map 破坏了。
cloudy GoCN 每日新闻 (2021-11-21) 中提及了此贴 11月22日 13:05
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册