3. 缓存基础:缓存的作用、常见缓存策略(LRU、TTL)、Go 中内存缓存实现

在分布式系统与 gRPC 服务中,缓存是提升读取性能、降低后端存储压力的核心手段。本章从缓存的基本作用出发,深入剖析两种最常用的缓存策略——LRU(最近最少使用)与 TTL(生存时间),并给出 Go 语言中内存缓存的标准实现模式。理解这些基础,是后续实现 gRPC 契约缓存一致性的前提。

3.1 缓存的核心作用

缓存通过在内存中暂存热点数据,减少对数据库、磁盘或远程服务的重复访问。在 gRPC 场景下,缓存主要解决以下问题:

  • 降低延迟:内存访问延迟通常在纳秒级,而数据库或远程 gRPC 调用在毫秒级,缓存可将响应时间降低 1~3 个数量级。
  • 提升吞吐量:缓存吸收重复请求,使后端服务能够处理更多并发。
  • 减轻后端压力:数据库连接池、磁盘 IO、外部 API 配额等瓶颈可通过缓存有效缓解。
  • 应对突发流量:缓存作为流量缓冲层,防止后端被瞬时峰值击穿。
关键指标:缓存命中率(Cache Hit Ratio)是衡量缓存效果的首要指标。命中率 = 缓存命中的请求数 / 总请求数。低于 80% 的命中率通常意味着缓存策略或容量需要调整。

3.2 常见缓存策略:LRU 与 TTL

缓存策略决定了数据何时被淘汰、何时过期。在分布式缓存一致性场景中,LRU 和 TTL 是最基础且最常用的两种策略,它们往往组合使用。

策略 核心思想 淘汰触发条件 适用场景 缺点
LRU(最近最少使用) 淘汰最长时间未被访问的数据 缓存容量达到上限 数据访问模式有明显热点,且访问频率随时间变化 无法处理“冷数据永不过期”问题;实现需要 O(1) 的链表+哈希表结构
TTL(生存时间) 数据写入后经过固定时间自动失效 时间到期(被动或主动过期) 数据有明确时效性,如会话 Token、验证码、价格快照 无法应对容量压力;大量同时过期可能导致缓存雪崩
LRU + TTL(组合) 同时考虑访问频率与时间窗口 容量满时 LRU 淘汰;时间到期时 TTL 淘汰 大多数生产级缓存(如 Redis、本地缓存库) 实现复杂度增加;需要权衡两种淘汰优先级
实践建议:在 gRPC 服务的内存缓存中,优先采用 LRU + TTL 组合策略。TTL 保证数据最终一致性窗口可控,LRU 保证热点数据不被冷数据挤占。

3.3 LRU 缓存原理与 Go 实现

LRU 缓存的核心数据结构是 双向链表 + 哈希表。哈希表提供 O(1) 的键查找,双向链表维护访问顺序:每次访问一个节点,将其移动到链表头部;当缓存满时,淘汰链表尾部的节点。

以下是一个简洁的 Go 语言 LRU 缓存实现,不依赖第三方库:

package lru

import "container/list"

type entry struct {
    key   string
    value interface{}
}

type Cache struct {
    maxEntries int
    ll         *list.List
    cache      map[string]*list.Element
}

func New(maxEntries int) *Cache {
    return &Cache{
        maxEntries: maxEntries,
        ll:         list.New(),
        cache:      make(map[string]*list.Element),
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    if elem, ok := c.cache[key]; ok {
        c.ll.MoveToFront(elem)
        return elem.Value.(*entry).value, true
    }
    return nil, false
}

func (c *Cache) Set(key string, value interface{}) {
    if elem, ok := c.cache[key]; ok {
        c.ll.MoveToFront(elem)
        elem.Value.(*entry).value = value
        return
    }
    elem := c.ll.PushFront(&entry{key, value})
    c.cache[key] = elem
    if c.ll.Len() > c.maxEntries {
        c.removeOldest()
    }
}

func (c *Cache) removeOldest() {
    elem := c.ll.Back()
    if elem != nil {
        c.ll.Remove(elem)
        delete(c.cache, elem.Value.(*entry).key)
    }
}
并发安全注意:上述实现未加锁,在多 goroutine 环境下必须使用 sync.RWMutex 或 sync.Map 保护。生产级实现建议直接使用 hashicorp/golang-lru 或 patrickmn/go-cache 等成熟库。

3.4 TTL 缓存原理与 Go 实现

TTL 缓存的核心是记录每个键的过期时间戳,并在读取时检查是否过期。过期清理策略分为惰性删除(读取时检查)和主动删除(后台定时扫描)。

以下是一个带 TTL 的简单内存缓存实现:

package ttl

import (
    "sync"
    "time"
)

type item struct {
    value      interface{}
    expiration int64
}

type Cache struct {
    mu    sync.RWMutex
    items map[string]*item
}

func New() *Cache {
    return &Cache{items: make(map[string]*item)}
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = &item{
        value:      value,
        expiration: time.Now().Add(ttl).UnixNano(),
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    it, ok := c.items[key]
    if !ok {
        return nil, false
    }
    if time.Now().UnixNano() > it.expiration {
        // 惰性删除:发现过期则返回不存在
        return nil, false
    }
    return it.value, true
}

// 主动清理:可启动 goroutine 定期调用
func (c *Cache) Cleanup(interval time.Duration) {
    ticker := time.NewTicker(interval)
    go func() {
        for range ticker.C {
            c.mu.Lock()
            now := time.Now().UnixNano()
            for k, it := range c.items {
                if now > it.expiration {
                    delete(c.items, k)
                }
            }
            c.mu.Unlock()
        }
    }()
}

3.5 缓存策略组合与架构示意

在实际 gRPC 服务中,LRU 与 TTL 通常组合使用:TTL 保证数据不会无限期驻留,LRU 保证热点数据在容量满时优先保留。下图展示了组合缓存的工作流程:

LRU + TTL 组合缓存工作流程 LRU + TTL 组合缓存工作流程 客户端请求 缓存查询 TTL 检查 LRU 更新 未过期 返回数据 淘汰处理 过期 容量满 容量满 说明 • 实线:正常命中路径 • 虚线:淘汰/过期路径

3.6 Go 中内存缓存的选型对比

Go 生态中有多个成熟的内存缓存库,适用于不同场景。下表对比了最常用的三个库:

库 策略 并发安全 淘汰机制 适用场景
hashicorp/golang-lru LRU 是(sync.Mutex) 容量满时淘汰最久未访问 需要严格 LRU 语义,数据无固定过期时间
patrickmn/go-cache TTL 是(sync.RWMutex) 惰性删除 + 主动清理 数据有明确 TTL,无需 LRU 淘汰
allegro/bigcache TTL + 分片 是(无锁分片) 惰性删除 + 主动清理 高并发、海量小对象,GC 压力敏感
选型建议:对于 gRPC 服务端缓存,如果数据量不大(百万级以内)且需要 LRU 语义,优先选择 hashicorp/golang-lru;如果数据量大且 GC 敏感,选择 allegro/bigcache;如果仅需 TTL 过期,patrickmn/go-cache 足够轻量。

3.7 缓存一致性初步:为什么需要契约缓存一致性

在单机内存缓存中,数据一致性由应用自身保证。但在分布式 gRPC 服务中,多个服务实例各自持有本地缓存,当数据源发生变更时,不同实例的缓存可能长时间不一致。下图展示了分布式缓存不一致的典型场景:

分布式缓存不一致场景 分布式缓存不一致场景 服务实例 A 缓存: 旧值 v1 服务实例 B 缓存: 旧值 v1 服务实例 C 缓存: 旧值 v1 数据源(数据库) 值已更新为 v2 问题 数据源已更新为 v2, 但三个实例仍返回 v1