🛠️

ISUCON向けにGoの便利キャッシュライブラリを作りました

2024/11/10に公開

ISUCON向けにGoのcacheライブラリを作成しています。

https://github.com/catatsuy/cache

このライブラリは、ISUCONのように高パフォーマンスが求められる場面での利用を想定し、次のような経緯で開発を始めました。

以前は、キャッシュ用のコード片をコピーして微調整しながら使っていましたが、最近のGoのアップデートにより、状況が大きく改善されました。Genericsが一般的に使えるようになり、型ごとにコードを修正する必要がなくなったほか、deferのパフォーマンス問題もほとんど解消されています。こうした背景から、よりシンプルかつ効率的なキャッシュライブラリが作れると考え、次の方針で開発に取り組みました。

  • Genericsを活用して型安全かつ柔軟なキャッシュ機能を提供
  • 標準ライブラリのみを使用して依存関係を最小限に
  • 実際の利用を想定したシンプルで効率的な実装

使い方は以下で紹介しています。

https://pkg.go.dev/github.com/catatsuy/cache

この記事では日本語で簡単に使い方を紹介します。

機能

本ライブラリには、大きく分けて以下の3つの機能があります。

  1. 単純なキャッシュ

    • sync.Mutexまたはsync.RWMutexを利用でき、WriteまたはReadのどちらがHeavyかに応じて使い分け可能です。
    • 期限付きキャッシュや、数値に限定したインクリメント機能も提供しています。
  2. 単純なロック

    • DB側でなくアプリケーション側でのロックを取りたい場合に利用可能です。
  3. Generics対応のsingleflight

    • 本家のsingleflightはGenericsに対応していないため、型キャストが必要

以下に各機能の具体的な使い方を紹介します。

キャッシュの使用方法

キャッシュの基本的な使い方です。ここではWriteHeavyを例にしていますが、ReadHeavyも選択可能です。

package main

import (
	"fmt"

	"github.com/catatsuy/cache"
)

func main() {
	c := cache.NewWriteHeavyCache[int, string]()

	c.Set(1, "apple")
	value, found := c.Get(1)

	if found {
		fmt.Println("Found:", value)
	} else {
		fmt.Println("Not found")
	}
}

期限付きのキャッシュ設定も簡単です。

package main

import (
	"fmt"
	"time"

	"github.com/catatsuy/cache"
)

func main() {
	c := cache.NewReadHeavyCacheExpired[int, string]()

	// 1秒の期限付きでアイテムをセット
	c.Set(1, "orange", 1*time.Second)

	// アイテムをすぐに取得
	if value, found := c.Get(1); found {
		fmt.Println("Found:", value) // Output: Found: orange
	} else {
		fmt.Println("Not found")
	}

	// アイテムが期限切れになるのを待機
	time.Sleep(2 * time.Second)
	if _, found := c.Get(1); !found {
		fmt.Println("Item has expired") // Output: Item has expired
	}
}

数値型に限定してインクリメントできるキャッシュも提供しています。

package main

import (
	"fmt"

	"github.com/catatsuy/cache"
)

func main() {
	c := cache.NewWriteHeavyCacheInteger[int, int]()

	c.Set(1, 100)
	c.Incr(1, 10) // 10でインクリメント

	value, found := c.Get(1)

	if found {
		fmt.Println("New Value:", value) // Output: New Value: 110
	} else {
		fmt.Println("Not found")
	}
}

ロックの使用方法

以下のコードで、特定のリソースをロック・アンロックできます。

package main

import (
	"fmt"
	"github.com/catatsuy/cache"
)

func main() {
	lm := cache.NewLockManager[int]()

	// 特定のキーでリソースをロック
	lm.Lock(1)
	fmt.Println("Resource 1 is locked")

	// ロックされたリソースでの作業
	fmt.Println("Resource 1 is being used")

	// リソースのロックを解除
	lm.Unlock(1)
	fmt.Println("Resource 1 is unlocked")
}

効率性を高める場合は、GetAndLock関数を利用することで、mapの走査回数を減らせます。この関数はsync.Mutexを直接返すため、変数に保持しておくことやdeferでアンロックすることも簡単にできます。

package main

import (
	"fmt"
	"github.com/catatsuy/cache"
	"time"
)

var lm = cache.NewLockManager[int]()

func main() {
	heavyOperation(1)
	heavyOperation(2)
}

func heavyOperation(id int) {
	// LockManagerから直接Mutexを取得し、deferでアンロック
	defer lm.GetAndLock(id).Unlock()

	fmt.Printf("Starting heavy operation on resource %d\n", id)
	time.Sleep(2 * time.Second)
	fmt.Printf("Completed heavy operation on resource %d\n", id)
}

singleflightの使用方法

singleflightもGenerics対応しており、型キャストを省けるのが特徴です。

package main

import (
	"fmt"

	"github.com/catatsuy/cache"
)

func main() {
	sf := cache.NewSingleflightGroup[string]()

	// データの読み込み関数
	loadData := func(key string) (string, error) {
		return fmt.Sprintf("Data for key %s", key), nil
	}

	value, err := sf.Do("key", func() (string, error) {
		return loadData("key")
	})

	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Result:", value) // Output: Result: Data for key 1
	}
}

以下は、キャッシュ機能とsingleflightを組み合わせた利用例です。キャッシュを確認し、存在しない場合のみ重い処理を実行し、結果をキャッシュに保存します。

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/catatsuy/cache"
)

var (
	c  = cache.NewWriteHeavyCache[int, int]()
	sf = cache.NewSingleflightGroup[int]()
)

func Get(key int) int {
	if value, found := c.Get(key); found {
		return value
	}

	v, err := sf.Do(fmt.Sprintf("cacheGet_%d", key), func() (int, error) {
		value := HeavyGet(key)
		c.Set(key, value)
		return value, nil
	})
	if err != nil {
		panic(err)
	}

	return v
}

func HeavyGet(key int) int {
	log.Printf("call HeavyGet %d\n", key)
	time.Sleep(time.Second)
	return key * 2
}

func main() {
	for i := 0; i < 100; i++ {
		go func(i int) {
			Get(i % 10)
		}(i)
	}

	time.Sleep(2 * time.Second)

	for i := 0; i < 10; i++ {
		log.Println(Get(i))
	}
}

本家のsingleflightとの違い

本ライブラリは、本家の仕様から以下の変更を加えています。

  • Genericsに対応しているため、型キャストが不要
  • NewSingleflightGroup呼び出し時に内部のmapを作成するため、初回のDo呼び出し時にmapを作成する本家に比べ、余計なオーバーヘッドがない
  • panic時のエラーハンドリングは省略してシンプルに
  • 複数回同時に実行された数を返す機能は削除し、オーバーヘッドを軽減
  • 実行後の変数削除を非同期で行うことで、同時実行中の不要なロック待ちを解消
    • 非同期削除により、実行終了直後のリクエストで古い値が返る可能性があるが、singleflightの用途上、一般的なケースで問題にはならない想定

まとめ

ISUCON用に開発しているため、今後もISUCONに便利な機能を追加する可能性がありますが、依存は標準パッケージのみにこだわり、シンプルさを重視していく方針です。このライブラリが気に入った方は、ぜひGitHubでstarを付けていただけるとうれしいです。よろしくお願いします。

Discussion