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 保证热点数据在容量满时优先保留。下图展示了组合缓存的工作流程:
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 服务中,多个服务实例各自持有本地缓存,当数据源发生变更时,不同实例的缓存可能长时间不一致。下图展示了分布式缓存不一致的典型场景: