在 Go 專案中基於本地記憶體快取的實現及應用

yudotyang發表於2021-11-01

大家好,我是 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,
    }
}

總結

這種方式一般適用於資料量較小、頻繁讀的場景。在文章開始的圖中我們可以看到,因為是伺服器往往是叢集,所以每臺機器上的檔案內容可能會有短暫的差異,所以該實現也不適用於對資料具有強一致要求的場景中。

更多原創文章乾貨分享,請關注公眾號
  • 在 Go 專案中基於本地記憶體快取的實現及應用
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章