不是所有缓存都需要 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。泛型能让缓存类型更清楚,但不会自动解决容量、一致性和并发击穿问题。
缓存是优化手段,不是数据源。使用前要想清楚数据能旧多久、多实例是否需要一致、内存增长如何控制。把边界说明白,简单内存缓存就很实用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。