BigCacheは、Allegroが公開しているGo製のインメモリキャッシュです。数百万件のエントリを1つのプロセスに抱えても、GCの走査対象がほとんど増えない構造を持ちます。最新版は2026年8月14日公開のv3.2.0で、前版v3.1.0から約3年9か月ぶりの更新でした。
この記事では、GCの走査対象を減らす仕組み、v3.2.0での導入とConfigの決め方、運用時に注意する挙動、freecache・ristretto・otterとの使い分けを、go1.26.5での実測とソースコードの記述をもとに整理します。
まとめ:BigCache v3.2.0の要点
- 値は
[]byteだけを受け付けます。構造体はJSONなどでシリアライズしてから保存します。 - エントリはシャードごとの巨大なバイト列に詰め、索引は
map[uint64]uint64で持ちます。ポインタを含まないため、GCは中身を走査しません。 - go1.26.5で500万件を格納した今回の測定では、GC1回のマーク時間は
map[string][]byteの362msに対しBigCacheは1.0msでした。 - 有効期限(LifeWindow)は全キー一律で、Getしても延びません。キーごとのTTLが必要ならfreecacheかotterを選びます。
- 期限切れのエントリも、掃除(CleanWindow)が走るまで
Getで返ります。厳密に弾くならGetWithInfoで状態を見ます。 - 単純な読み取り速度ではristrettoやotterのほうが速く、BigCacheを選ぶ理由は件数が多い場合のGC負荷の小ささにあります。
BigCacheの仕組み:GCがエントリを走査しない理由
BigCacheは2016年、Allegroが社内のキャッシュサービス用に作ったライブラリです。同社の開発ブログによると、要件は合計10,000 rps(書き込み5,000・読み出し5,000)、数百万エントリの保持、応答時間は平均5ms未満・99.9パーセンタイル10ms未満でした。大量のエントリを普通のmapに入れると、GCがその全ポインタを毎回たどる。これを避けるための設計です。
索引のmapとバイト列キューの2層構造
Goのコンパイラ最適化の一覧には、要素型がポインタを含まないslice・channel・mapは、GCが基礎バッファを走査しないと書かれています(Go 1.5で入った最適化)。BigCacheはこれを利用して、データを2層に分けます。
- 索引:キー文字列のハッシュ値(既定はFNV-1a 64bit)を、エントリの位置に対応づける
map[uint64]uint64。キーも値も整数なのでGCは中を見ません。 - 本体:シャードごとに1本の
[]byteを環状キューとして使い、エントリを先頭から詰めます。GCから見えるのはスライス1本分のポインタだけです。
各エントリは、タイムスタンプ8バイト・ハッシュ8バイト・キー長2バイトの計18バイトのヘッダ、キー、値の順に書き込まれます。v3.1.0までは索引がmap[uint64]uint32でしたが、v3.2.0のソースでは位置の型がuint64に変わりました。READMEの「How it works」節は今もuint32と書いたままなので、内部を追うときはタグ付きのソースを見てください。
1024シャードとsync.RWMutexによる並行アクセス
キャッシュ全体はShards個のシャードに分かれ、キーのハッシュ値の下位ビットで振り分けられます。各シャードはsync.RWMutexを1つ持ち、Getは読み取りロック、Setは書き込みロックを取ります。既定値は1024シャードで、2のべき乗以外を指定するとNewが「Shards number must be power of two」を返します。ロック競合の考え方はGoのsync.MutexとRWMutexの違いと使い分けで実測しています。
go1.26.5で測ったGCのマーク時間
500万件(キーはkey-N、値は100バイト)を格納し、mapではキーごとに独立した値スライスを確保した状態でruntime.GC()を呼び、GODEBUG=gctrace=1の出力を比べました。環境はIntel Core i9-9880H(8コア16スレッド)、go1.26.5 darwin/amd64です。
| 格納先 | マーク時間(実時間) | マークのCPU時間 | STW停止の合計 | ヒープ上のオブジェクト数 | HeapAlloc |
|---|---|---|---|---|---|
| map[string][]byte | 362ms | 約4.6秒 | 0.057ms | 約1,000万 | 995MB |
| BigCache v3.2.0 | 1.0ms | 約1.9ms | 0.057ms | 約2.3万 | 1,226MB |
| freecache v1.2.7 | 0.27ms | 約0.6ms | 0.123ms | 約700 | 892MB |
計測の中心部分は次のとおりです。キャッシュごとに別プロセスで起動し、件数を詰めたあとで強制GCを繰り返しました。表には、最後の1回についてgctraceが出力したマーク時間・CPU時間・STW時間を載せています。
runtime.GC()
var best time.Duration = 1 << 62
for r := 0; r < 5; r++ {
t := time.Now()
runtime.GC()
if d := time.Since(t); d < best {
best = d
}
}
// 実行例: GODEBUG=gctrace=1 ./gcbin bigcache 5000000
世界を止めるSTW停止はどれも0.1ms前後で、差がつくのは並行マークに使うCPU時間です。mapは1サイクルごとに約4.6秒分のCPUをGCに取られ、その間アプリの処理に回るコアが減ります。READMEに載っている「GC pause」の比較はGo 1.13時代の数字で、今のGoでは停止時間よりもこのCPU消費として効いてきます。一方、ヒープ使用量はBigCacheが最も大きくなりました。後述の初期確保と倍々拡張のためです。
BigCacheのインストールと基本の使い方
go getとGoのバージョン要件
go get github.com/allegro/bigcache/[email protected]
v3.2.0のgo.modはgo 1.22を宣言しています。READMEには「Requires Go 1.12 or newer」とありますが、これは古い記述で、実際にはGo 1.22以上が必要です。初期化はNew(ctx, config)を使い、v2時代のNewBigCache(config)はv3.2.0のソースでDeprecatedになっています。
構造体をJSONで保存するラッパーの実装例
BigCacheのSetとGetは[]byteしか扱わないため、実務では型付きのラッパーを1枚かぶせます。次のコードはgo1.26.5とv3.2.0で実行を確認しました。期限切れのエントリをミスとして扱う処理も入れています。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
"github.com/allegro/bigcache/v3"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
type UserCache struct{ c *bigcache.BigCache }
func (u UserCache) Put(user User) error {
b, err := json.Marshal(user)
if err != nil {
return err
}
return u.c.Set(fmt.Sprintf("user:%d", user.ID), b)
}
// 期限切れ(CleanWindow 待ち)のエントリもミス扱いにする
func (u UserCache) Find(id int) (User, bool, error) {
b, resp, err := u.c.GetWithInfo(fmt.Sprintf("user:%d", id))
if errors.Is(err, bigcache.ErrEntryNotFound) || resp.EntryStatus == bigcache.Expired {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
var user User
if err := json.Unmarshal(b, &user); err != nil {
return User{}, false, err
}
return user, true, nil
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := bigcache.DefaultConfig(10 * time.Minute)
cfg.Shards = 256 // 2のべき乗
cfg.CleanWindow = 1 * time.Minute // 期限切れの掃除間隔
cfg.MaxEntriesInWindow = 100_000 // 10分間に入る件数の見積もり
cfg.MaxEntrySize = 256 // 1件あたりの平均バイト数の見積もり
cfg.HardMaxCacheSize = 64 // MB。0は上限なし
cfg.Verbose = false // DefaultConfig は true
cfg.StatsEnabled = true
cfg.OnRemoveWithReason = func(key string, _ []byte, r bigcache.RemoveReason) {
if r == bigcache.NoSpace {
log.Printf("evicted by size limit: %s", key)
}
}
c, err := bigcache.New(ctx, cfg)
if err != nil {
log.Fatal(err)
}
defer c.Close()
users := UserCache{c}
if err := users.Put(User{ID: 1, Name: "Sato"}); err != nil {
log.Fatal(err)
}
u, ok, err := users.Find(1)
if err != nil {
log.Fatal(err)
}
fmt.Println(u, ok)
_, ok, err = users.Find(2)
if err != nil {
log.Fatal(err)
}
fmt.Println("id=2 hit:", ok)
fmt.Printf("%+v len=%d\n", c.Stats(), c.Len())
}
実行結果は次のとおりです。Stats()によるヒット・ミス・衝突の集計は常時有効です。StatsEnabledはキー別のリクエスト回数を追加集計する設定で、有効にするとGetがヒットするたびにシャードの書き込みロックを取ります(v3.2.0のhit関数)。読み取りの多い本番環境では、KeyMetadataでキー別の回数を見る必要がない限り無効のままにします。
{1 Sato} true
id=2 hit: false
{Hits:1 Misses:1 DelHits:0 DelMisses:0 Collisions:0} len=1
キーの不在はErrEntryNotFoundとerrors.Isで判定します。ミスが出たときにDBへの問い合わせが同時に殺到するのを防ぎたい場合は、singleflightによる重複呼び出しの抑制と組み合わせます。
BigCacheのConfig設定の決め方
| 項目 | DefaultConfigの値 | 役割 |
|---|---|---|
| Shards | 1024 | シャード数(2のべき乗) |
| LifeWindow | 引数で指定 | 全キー共通の有効期間 |
| CleanWindow | 1秒 | 期限切れを掃除する間隔 |
| MaxEntriesInWindow | 600,000 | 初期確保の件数見積もり |
| MaxEntrySize | 500 | 初期確保の1件サイズ(バイト) |
| HardMaxCacheSize | 0(無制限) | キュー全体の上限(MB) |
| Verbose | true | メモリ追加確保のログ出力 |
| StatsEnabled | false | キー別のリクエスト回数集計 |
| Hasher | FNV-1a 64bit | キーのハッシュ関数 |
MaxEntriesInWindow・MaxEntrySizeによる初期確保量の見積もり
この2つは上限ではなく、起動時に確保するバイト列の大きさを決めるだけの値です。READMEの設定例のコメントには「rps * lifeWindow」とあり、毎秒の書き込み件数に有効期間(秒)を掛けた数を入れます。毎秒200件・10分なら120,000件です。見積もりが小さすぎると容量不足のたびに倍々で再確保が起き、Verboseがtrueだとその都度ログが出ます。CleanWindowは1秒未満にしても意味がありません。BigCacheの時刻は1秒単位で、LifeWindowを1秒未満にしてCleanWindowを設定するとNewがエラーを返します。
HardMaxCacheSizeとNoSpaceによる押し出し
HardMaxCacheSizeはMB単位の上限で、達すると最も古いエントリから押し出されます。シャード1個・上限1MBで1KBの値を3,000件入れると、残ったのは1,023件でした。押し出された1,977件はOnRemoveWithReasonに理由NoSpace付きで通知されます。上限はシャード数で割った値がシャードごとの上限になるため、1件がそれを超えるとSetは「entry is bigger than max shard size」で失敗します。1MBのシャードに2MBの値を入れて確認しました。実メモリは索引のmapの分だけこの上限を超える点も、ソースのコメントに明記されています。
BigCacheで踏みやすい5つの挙動(v3.2.0で実測)
READMEの短い説明からは読み取りにくい挙動を、実際にコードを動かして確かめました。どれも仕様どおりの動きで、バグではありません。
Getの期限判定と削除前の期限切れエントリ
LifeWindowを1秒、CleanWindowを10秒にして、Setから2.5秒後にGetすると、値がエラーなしで返りました。同じタイミングでGetWithInfoを呼ぶと、EntryStatusはExpiredです。Getは期限を確認しないためで、期限切れのエントリが消えるのは、定期掃除が走ったとき、容量不足で押し出されたとき、定期掃除を無効にした状態で次の書き込みがあったときのいずれかです。BigCacheの期限判定に従って期限切れを除外するには、前出のラッパーのようにGetWithInfoを使います。ただし時刻とLifeWindowの判定は秒単位なので、ミリ秒精度の期限管理には別の仕組みが必要です。
Getによる参照と有効期限の非延長
BigCacheの淘汰は挿入順のFIFOで、LRUではありません。LifeWindow2秒で1秒ごとにGetし続けたキーも、3秒後にはErrEntryNotFoundになりました。BigCacheはアクセス頻度で保持対象を選ばないため、容量が足りない状況で人気キーを残したい用途では不利になる場合があります。ヒット率の差は実際のアクセス分布で比較します。
同一キーの上書きによる追記とメモリ増加
Setは既存エントリを書き換えず、キューの末尾に新しいエントリを追記して古いほうを無効化します。1KBの値を同じキーに1,000回Setすると、Len()は1のままなのにCapacity()は1,000バイトから1,036,288バイトへ伸びました。無効化された領域は、期限切れか容量不足で押し出されるまで回収されません。カウンタのように頻繁に上書きするデータには向きません。
ハッシュ衝突による既存キーの上書き
READMEは「BigCache does not handle collisions」と明記しています。ハッシュ値が同じキーをSetすると後のキーが前のキーを上書きし、前のキーをGetするとErrEntryNotFoundが返ります。必ず衝突するHasherを差し込んで確認したところ、Len()は1、Stats().Collisionsは1でした。64bitハッシュで衝突する確率は件数が数百万でも非常に小さいものの、キャッシュを正本のデータ置き場にしてはいけない理由の1つです。
DefaultConfigのVerbose既定値とログ出力
DefaultConfigはVerbose: trueを返すため、キューの追加確保や衝突のたびにログが出ます。v3.2.0では、衝突ログはConfig.Loggerへ渡されますが、追加確保ログは標準パッケージのlog.Printfを直接呼びます。Loggerを差し替えても追加確保ログの出力先は変わらないため、構造化ログに揃えたいサービスではVerboseをfalseにします。
freecache・ristretto・otterとBigCacheの比較
淘汰方式と有効期限の違い
| ライブラリ | 最新版(公開日) | 保存する値 | 淘汰方式 | キー別TTL | 容量 |
|---|---|---|---|---|---|
| BigCache | v3.2.0(2026-08-14) | []byte | 挿入順FIFO | なし | 自動拡張(上限は任意) |
| freecache | v1.2.7(2026-03-19) | []byte | Nearly LRU | あり | 生成時に固定 |
| ristretto | v2.4.2(2026-07-07) | 任意の型 | TinyLFU+SampledLFU | あり | コストの合計で制限 |
| otter | v2.3.0(2025-12-22) | 任意の型 | W-TinyLFU | あり | 件数か重みで制限 |
freecacheもBigCacheと同じくGC負荷を抑える設計で、データを256セグメントに分け、件数にかかわらずポインタは512本だけと説明しています。違いは容量で、freecacheは生成時のサイズで固定され、最小でも512KBを確保します。BigCacheは上限を決めなければ自動で拡張します。ristrettoとotterはGoの値をそのまま持つ汎用キャッシュで、ヒット率を上げる入場・淘汰方式に力を入れています。ristrettoのSetはバッファを経由する非同期処理で、READMEにある通り投入した値が落とされることがあります。
並列Get・Setの実測
Getでは1,048,576キーを事前投入し、Setでは事前投入せずに測定しました。各ゴルーチンは異なる開始位置からキー配列を順番に巡回します。GetとSetをgo test -benchで3回ずつ測った中央値です。値は100バイト、環境は先のGC計測と同じです。表の値はRunParallelが出力するns/opで、全体の経過時間を総操作数で割った値です。個々のリクエストの応答時間ではありません。また、Getのヒット率とSetの採用率は測定していません。
| 実装 | Get 1並列 | Get 16並列 | Getのallocs/op | Set 16並列 |
|---|---|---|---|---|
| BigCache | 1,011ns | 150.0ns | 2 | 180.8ns |
| freecache | 1,221ns | 180.9ns | 1 | 132.2ns |
| ristretto | 338.1ns | 43.34ns | 0 | 120.2ns |
| otter | 416.7ns | 43.82ns | 0 | 1,819ns |
| map+RWMutex | 270.2ns | 133.1ns | 0 | 694.1ns |
16並列の読み取りでは、ristrettoとotterの処理スループットはBigCacheの約3.4〜3.5倍でした。BigCacheは、今回の1並列・16並列の両条件でmapを1本のRWMutexで守る実装よりns/opが大きい結果でした。BigCacheとfreecacheは読むたびに値をコピーして新しいスライスを返す(BigCacheのソースにも「copy on read」とある)ため、1回ごとに割り当てが発生します。ristrettoとotterは格納したスライスをそのまま返すのでコピーがありません。書き込みはmap+RWMutexが並列度16で遅くなり、シャード分割の効果が出ています。otterのSetは最大件数を1,048,576件に設定した条件で1,819nsでした。この測定では淘汰回数を記録していないため、他実装との差の原因は特定できません。
BigCacheを選ぶ場面と選ばない場面
BigCacheの強みは、保存件数が増えても格納領域のGC走査対象を増やしにくい点にあります。読み取り時のコピーやメモリの追加確保による割り当ては発生するため、GC全体のCPU消費が一定になるわけではありません。判断の目安は次のとおりです。
- BigCacheが合う:数百万件規模で、値がもともと
[]byte(JSONレスポンス・シリアライズ済みのセッション等)、有効期限が全件一律、容量の上限を事前に決めにくい。 - freecacheが合う:同じくGC負荷を抑えたいが、キーごとにTTLを変えたい、またはデータを保持するリングバッファの容量を起動時に固定したい。索引などの追加メモリは別途必要です。
- ristrettoかotterが合う:Goの構造体をシリアライズせずに持ちたい、よく読まれるキーを優先して残したい。採用時は想定件数とアクセス分布でGC負荷とヒット率を測定します。
- Redis系の共有キャッシュが合う:複数のサーバーで同じキャッシュを見たい、プロセス再起動後も中身を残したい。BigCacheはプロセス内のメモリにしか置けません。マネージドの選択肢はAmazon ElastiCacheの対応エンジンと採用判断やRedis互換のValkeyで整理しています。
数万件程度のキャッシュでは、まずmapとロックによる実装を候補にします。実際のGC負荷は値の構造や更新頻度にも左右されます。mapのままでGC負荷が問題にならないなら、BigCacheに替えると増える読み取り時のコピーやシリアライズは不要なコストです。同じキーを頻繁に上書きするデータや、人気キーを優先して残したいデータにも、上書き時の追記とFIFOの淘汰が不利に働きます。
BigCacheについてよくある質問
BigCacheはキーごとに有効期限を設定できますか?
できません。有効期限はConfigのLifeWindowだけで、全キーに一律で適用されます。キーごとの期限が必要なら、freecacheのSetの第3引数(秒)や、otterのSetExpiresAfter、ristrettoのSetWithTTLを使います。
BigCacheは複数のゴルーチンから安全に使えますか?
使えます。各シャードがsync.RWMutexを持ち、Get・Set・Deleteはロックの内側で処理されます。呼び出し側で追加のロックは要りません。ただしGetが返すのはコピーなので、返ってきたスライスを書き換えてもキャッシュの中身は変わりません。
DeleteやResetをしてもメモリ使用量が減らないのはなぜですか?
BigCacheのキューは容量不足のたびに倍々で拡張し、縮小しないためです。Reset()はキューの位置を先頭へ戻すだけで、確保済みの配列は保持します。1MB強まで伸びたキャッシュでDeleteとResetを呼んでも、Capacity()は1,036,288バイトのままでした。READMEも、Goランタイムが解放済みのメモリをすぐOSへ返さないため、プロセスのメモリ使用量は減って見えないと説明しています。
NewBigCacheとNewのどちらを使えばよいですか?
New(ctx, config)を使います。v3.2.0のソースでNewBigCacheはDeprecatedになっており、内部ではcontext.Background()を渡してNewと同じ初期化処理を呼んでいます。Newに渡したcontextをキャンセルするかClose()を呼ぶと、掃除用のゴルーチンが止まります。
BigCacheはHTTPのキャッシュサーバーとしても使えますか?
使えます。リポジトリのserverパッケージに、BigCacheをHTTP経由で読み書きするサーバー実装が同梱されており、GET・PUT・DELETEによるキャッシュの読み書きと、統計・全消去のエンドポイントを持ちます。ただし複数のサーバーで共有するならレプリケーションや永続化の機能はないため、RedisやValkeyを検討します。