Go 内存缓存入门:TTL、并发安全和清理策略

用配置查询场景讲 Go 中简单内存缓存的实现,包括 mutex、TTL、过期判断、定期清理和适用边界。

不是所有缓存都需要 Redis。很多小型服务只想把低频变化的配置、字典表、权限规则在进程内缓存几分钟,减少数据库查询。Go 里写一个简单内存缓存并不难,但要注意并发安全、过期时间和清理策略。

本文写一个带 TTL 的内存缓存。它适合入门理解,不打算替代成熟缓存库。

数据结构

type entry[V any] struct {
	value     V
	expiresAt time.Time
}

type Cache[K comparable, V any] struct {
	mu    sync.RWMutex
	items map[K]entry[V]
}

func NewCache[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{items: make(map[K]entry[V])}
}

key 要做 map key,所以是 comparable。value 可以是任意类型。

Set 和 Get

func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()

	if c.items == nil {
		c.items = make(map[K]entry[V])
	}
	c.items[key] = entry[V]{
		value:     value,
		expiresAt: time.Now().Add(ttl),
	}
}

读取:

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	e, ok := c.items[key]
	c.mu.RUnlock()

	var zero V
	if !ok {
		return zero, false
	}
	if time.Now().After(e.expiresAt) {
		c.mu.Lock()
		delete(c.items, key)
		c.mu.Unlock()
		return zero, false
	}
	return e.value, true
}

这里用读锁先拿 entry,过期时再加写锁删除。简单实现足够清楚。严格来说,删除前可以再次确认 entry 是否还是同一个,避免并发更新后被误删。入门阶段先理解基本模式。

用在服务里

type ConfigService struct {
	store Store
	cache *Cache[string, AppConfig]
}

func (s *ConfigService) GetConfig(ctx context.Context, name string) (AppConfig, error) {
	if cfg, ok := s.cache.Get(name); ok {
		return cfg, nil
	}
	cfg, err := s.store.LoadConfig(ctx, name)
	if err != nil {
		return AppConfig{}, err
	}
	s.cache.Set(name, cfg, 5*time.Minute)
	return cfg, nil
}

这会带来一个问题:缓存过期瞬间,多个请求可能同时查数据库。高并发热门 key 可以再配合 singleflight。普通配置读取并发不高时,这个简单版本已经够用。

定期清理

如果某些 key 过期后再也没人读,Get 不会触发删除。可以定期清理:

func (c *Cache[K, V]) DeleteExpired(now time.Time) {
	c.mu.Lock()
	defer c.mu.Unlock()

	for key, e := range c.items {
		if now.After(e.expiresAt) {
			delete(c.items, key)
		}
	}
}

后台循环:

func (c *Cache[K, V]) StartJanitor(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	go func() {
		defer ticker.Stop()
		for {
			select {
			case now := <-ticker.C:
				c.DeleteExpired(now)
			case <-ctx.Done():
				return
			}
		}
	}()
}

后台 goroutine 要能退出,所以传入 context。不要启动一个永远无法停止的清理任务。

适用边界

内存缓存的优点是快、简单、没有网络依赖。缺点也明显:每个进程都有自己的缓存,服务重启后丢失,多实例之间不一致,容量不受控时可能占内存。

适合缓存:

  • 低频变化配置
  • 小型字典表
  • 计算结果短期复用
  • 对一致性要求不高的数据

不适合缓存:

  • 强一致余额
  • 权限变更必须立即生效的核心判断
  • 大对象无限增长
  • 多实例必须共享的状态

缓存一定会带来一致性问题。TTL 越长,数据库压力越小,但数据越旧。要根据业务选择。

测试过期

时间相关测试最好避免真的 sleep 很久。可以把过期判断函数拆出来,或给 DeleteExpired 传入 now。简单测试:

func TestCacheExpires(t *testing.T) {
	cache := NewCache[string, string]()
	cache.Set("name", "go", time.Nanosecond)
	time.Sleep(time.Millisecond)

	if _, ok := cache.Get("name"); ok {
		t.Fatal("expected value to expire")
	}
}

这类测试有一点时间依赖,但时间很短。更严谨的版本可以注入 clock。入门阶段先保证行为可见。

容量也需要边界

TTL 只能保证数据最终过期,不能保证缓存不会在短时间内变得很大。如果 key 来自用户输入,攻击者可以构造大量不同 key,让内存缓存快速增长。因此简单缓存也应该考虑容量上限。

最简单的保护是写入前检查长度:

func (c *Cache[K, V]) SetWithLimit(key K, value V, ttl time.Duration, maxItems int) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.items == nil {
		c.items = make(map[K]entry[V])
	}
	if len(c.items) >= maxItems {
		for k := range c.items {
			delete(c.items, k)
			break
		}
	}
	c.items[key] = entry[V]{value: value, expiresAt: time.Now().Add(ttl)}
}

这不是严格 LRU,只是入门级保护。生产里如果缓存很关键,可以使用成熟库实现 LRU、LFU 或 TinyLFU。重点是不要让 map 无限制增长。

缓存值是否可以被修改

如果缓存里放的是指针、map 或切片,调用方拿到后修改,可能会影响缓存中的值:

cfg, _ := cache.Get("app")
cfg.Rules = append(cfg.Rules, "new")

如果 Rules 底层切片被共享,后续请求可能看到被意外修改的配置。简单做法是缓存不可变数据,或者在 Get/Set 时复制。对入门项目来说,至少要在注释里说明返回值能不能修改。缓存不仅是并发问题,也是所有权问题。

小结

Go 内存缓存可以用 map 加 mutex 实现,TTL 用过期时间控制,读取时判断过期,后台清理长期不用的 key。泛型能让缓存类型更清楚,但不会自动解决容量、一致性和并发击穿问题。

缓存是优化手段,不是数据源。使用前要想清楚数据能旧多久、多实例是否需要一致、内存增长如何控制。把边界说明白,简单内存缓存就很实用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页