😶‍🌫️

go-cacheでキャッシュを実装する

2024/05/19に公開

直近でGoでキャッシュを実装する方法を学んだので簡単にまとめます!

キャッシュとは

キャッシュとは、データや計算結果を一時的に保存しておくための仕組みです。これにより、同じデータや計算を再度取得・実行する際に高速化を図ることができます。キャッシュの主な目的は、システムのパフォーマンスを向上させ、レスポンスタイムを短縮することです。
例えば以下のように、クライアントとデータベースの間などにキャッシュを用意し、頻繁に使うデータや計算量の多いデータなどをキャッシュに保存しておくことで、素早くクライアントに届けられるようになります。

Goのキャッシュパッケージ

Goにはさまざまな用途に沿ったキャッシュのパッケージがあります。以下は簡単な紹介です。

  1. cachegolang-lru/cache

(https://github.com/hashicorp/golang-lru)

  • キャッシュがいっぱいになったときに最も古く使われていないアイテムを削除する(LRUアルゴリズム)
  • キャッシュのサイズ(エントリ数)を制限できる
  1. go-cache

(https://github.com/patrickmn/go-cache)

  • 各アイテムに有効期限を設定できる(TTLサポート)
  • 期限切れのアイテムを定期的に削除する(自動クリーニング)
  1. bigcache

(https://github.com/allegro/bigcache)

  • メモリ効率がよく、ガベージコレクションの負担を軽減できる
  • 大量リクエストを高速に処理できる
  1. ristretto

(https://github.com/dgraph-io/ristretto)

  • 大量リクエストを高速に処理できる
  • 高度なメモリ管理機能を持つ

この他にもbigcacheのベースになったfreecacheなどいろいろあります。今回は特に使いやすくて利用率も高いgo-cacheを使って、クライアント-データベース間にキャッシュを実装していきます。

go-cacheの使い方

go-cacheの簡単なコードを書いてみました。キャッシュの実装についてはデータベースと似ています。
流れとしては、キャッシュを用意→データを保存→データを取得という感じですね。

package main

import (
	"fmt"
	"time"

	"github.com/patrickmn/go-cache"
)

func main() {
	// 新たにキャッシュを用意
        // データの有効期限を5分、キャッシュリフレッシュ期間を10分に設定
	c := cache.New(5*time.Minute, 10*time.Minute)

	// キャッシュにデータを保存
        // 第3引数でデータごとに有効期限を設定できる(今回のDefaultは5分)
	c.Set("key", "value", cache.DefaultExpiration)

	// キャッシュからデータを取得
	if x, found := c.Get("key"); found {
		fmt.Println("Found value:", x)
	}
}

go-cacheの特徴として、各アイテムごとの有効期限の設定や、期限切れのアイテムを定期的に削除できる機能が簡単に実装できることが挙げられます。

では、データの取得リクエストがあったときにデータベースを探す前にこのキャッシュ上を探して、ここにデータがなければデータベースを探しにいくといったコードを書いていきましょう。

データベースの実装

データベースとその操作には簡単にSqlite3とgormを使用していきます。
まずは以下のようにデータベースからpersonを取得するシンプルなコードを用意します。

package main

import (
	"fmt"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

var (
	DB    *gorm.DB
)

type Person struct {
	ID   int `gorm:"primarykey,autoincrement:true"`
	Name string
	Age  int
}

func init() {
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect to database")
	}

	db.AutoMigrate(&Person{})
	DB = db
}

func GetPerson(ID int) (*Person, error) {
	person := Person{}
	if err := DB.First(&person, ID).Error; err != nil {
		return nil, err
	}
	return &person, nil
}

func main() {
	person := Person{Name: "Jinzhu", Age: 18}
	DB.Create(&person)

	gotPerson, _ := GetPerson(person.ID)
	fmt.Println(gotPerson.Name)
	fmt.Println(gotPerson.Age)
}

このコードにgo-cacheを追加して、GetPerson()でまずキャッシュにデータがないかを確認し、あればそこからデータを取得、なければデータベースまで取得しにいくようなコードを書いていきます。

go-cacheの実装

まずはキャッシュの初期設定です。init()の一番下にキャッシュの設定を追加しましょう。

func init() {
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect to database")
	}

	db.AutoMigrate(&Person{})
	DB = db

	// キャッシュの初期設定
	Cache = cache.New(5*time.Minute, 10*time.Minute)
}

有効期限やクリーンアップのインターバルは都度調整してください。

次に、GetPerson()にキャッシュ上のデータを探して存在していればキャッシュから取得する処理を追加します。以下のようにGetPerson()を変更してみました。

func GetPerson(ID int) (*Person, error) {
	// キャッシュから指定のデータを取得
	if x, found := Cache.Get(fmt.Sprintf("person_%d", ID)); found {
		fmt.Println("got from cache")
		return x.(*Person), nil
	}

	person := Person{}
	if err := DB.First(&person, ID).Error; err != nil {
		return nil, err
	}
	fmt.Println("got from db")
	return &person, nil
}

go-cacheのキャッシュ上にはデータは{"key": "value"}としてstr型で保存されています。なので、Cache.Get()の引数に入れるkeystr型に変換する必要があります。

これでクライアントからGETリクエストが来た際に、まずはキャッシュを確認し、その後データベースにアクセスするGetPerson()が作成できました。

ですがアクセスされたデータをキャッシュに保存しないと、同じデータに何度もGETリクエストが来ても毎回データベースにアクセスすることになります。ですので、GetPerson()の最後を以下のように修正して、データベースにアクセスして得たデータをキャッシュに保存するステップを追加します。

func GetPerson(ID int) (*Person, error) {
	// キャッシュから指定のデータを取得
	if x, found := Cache.Get(fmt.Sprintf("person_%d", ID)); found {
		fmt.Println("got from cache")
		return x.(*Person), nil
	}

	person := Person{}
	if err := DB.First(&person, ID).Error; err != nil {
		return nil, err
	}
	// データベースにアクセスして得たデータをキャッシュに保存
	Cache.Set(fmt.Sprintf("person_%d", ID), &person, cache.DefaultExpiration)
	fmt.Println("got from db")
	return &person, nil
}

これでこの後5分間はこのデータはキャッシュに保存されているので、データベースにアクセスせずにクライアントに返すことができるようになりました。

キャッシュからデータ取得できているかを確認

では最後に確認として、main()で連続して2回同じデータを取得してみましょう。

func main() {
	person := Person{Name: "Jinzhu", Age: 18}
	DB.Create(&person)

	// 1回目(データベースから取得)
	gotPerson, _ := GetPerson(person.ID)
	fmt.Println(gotPerson.Name)
	fmt.Println(gotPerson.Age)

	// 2回目(キャッシュから取得)
	gotPerson, _ = GetPerson(person.ID)
	fmt.Println(gotPerson.Name)
	fmt.Println(gotPerson.Age)
}

これでコードを実行してみると、以下のように1回目はデータベースから、2回目はキャッシュからそれぞれデータが取得できました。

got from db
Jinzhu
18
got from cache
Jinzhu
18

キャッシュの理解を深めるために

キャッシュはデータベース以外にもさまざまなところで利用されています。私も今回のような簡単な用例から少しずつ知識を広めようと思います!

なお、キャッシュを体系的に理解するのに以下の動画がとても参考になりました!(冒頭の図もこの動画から引用)

https://youtu.be/bP4BeUjNkXc?si=O9PljqsKFNIBEQH1

Discussion