不是所有缓存都需要 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 时复制。对入门项目来说,至少要在注释里说明返回值能不能修改。缓存不仅是并发问题,也是所有权问题。
常见问题 FAQ
Q: RWMutex 比 Mutex 快吗?
A: 读多写少时 RWMutex 可以并发读,理论上更快。但如果读持有时间很短(如本例缓存命中),普通 Mutex 可能反而更简单高效。具体要用 benchmark 验证,不要假设 RWMutex 一定更好。
Q: 定时清理的 goroutine 数量怎么控制?
A: 一个缓存实例一个清理 goroutine 足够。如果缓存分片多个,每个分片一个清理任务。数量庞大时可考虑合并成一个全局清理器遍历多个缓存。
Q: 负缓存该怎么做?
A: 不存在的 key 也可以缓存一个标记值,过期时间通常短一些:
type cacheValue struct {
val any
found bool
}
这样能避免不存在的 key 反复穿透到数据源。
常见陷阱
- 读锁内判断过期,释放后才删:期间可能有另一个 goroutine 已经更新了这个 key,导致误删。本例简单直接删属于"够用就好",严格场景应二次确认。
- TTL 和 janitor 间隔不匹配:janitor 间隔太长,过期 key 长期不清理;太短,频繁遍历 map 浪费 CPU。
- 忘记处理 janitor goroutine 退出:未传入 context 的清理 goroutine 会成为僵尸 goroutine。
对比表
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| map + mutex | 简单、零依赖 | 无淘汰策略 | 小型应用、配置缓存 |
| 本文实现 | 有 TTL 和清理 | 非 LRU、无分片 | 中等规模服务 |
| go-cache | 成熟、功能全 | 引入外部依赖 | 生产环境适中 |
| groupcache/bigcache | 高性能、分片 | 学习成本高 | 高并发大数据 |
| Redis | 集群共享、持久化 | 网络延迟 | 多实例共享数据 |
性能提示
- 缓存的 key 不要太大,避免 Map 的 bucket 负担。
- value 尽量不可变,避免引用逃逸。
- 清理函数的遍历成本随 map 大小增长,超大的缓存考虑分片,每个分片独立 lock + map。
- benchmark 时带上并发读的性能:
go test -bench=. -benchmem。
小结
Go 内存缓存可以用 map 加 mutex 实现,TTL 用过期时间控制,读取时判断过期,后台清理长期不用的 key。泛型能让缓存类型更清楚,但不会自动解决容量、一致性和并发击穿问题。
缓存是优化手段,不是数据源。使用前要想清楚数据能旧多久、多实例是否需要一致、内存增长如何控制。把边界说明白,简单内存缓存就很实用。高并发场景建议配合 singleflight 和容量上限一起使用。
缓存指标与监控
给缓存接入基本指标:
func (c *Cache[K, V]) GetWithStats(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
}
增加命中率和未命中率统计,能帮助判断缓存配置是否合理。
TTL 策略对比
| 策略 | 优点 | 缺点 |
|---|---|---|
| 读取时过期 | 实时、准确 | 过期key长期不清理 |
| 定时清理 | 内存可控 | 有一定延迟 |
| 写入时随机过期 | 分散压力 | 实现复杂 |
| 惰性+定期混合 | 均衡 | 代码量最多 |
入门阶段用"读取时过期+定时清理"混合策略就足够了。
缓存穿透与击穿
缓存穿透是指大量请求查询不存在的key,每次都直接打到数据源。可以用负缓存:
func (c *Cache[K, V]) SetNegative(key K, ttl time.Duration) {
var zero V
c.Set(key, zero, ttl)
}
缓存击穿是指热点key过期瞬间大量请求涌入。解决方式正是前面讲的 singleflight。
长期维护要点
缓存上线后不是一劳永逸。TTL 设置是否合理需要观察指标:命中率过低说明 TTL 太短或缓存没覆盖到热点;命中率过高但数据经常过期,说明 TTL 太短。建议定期(每月或每季度)评估缓存效果,结合实际业务数据调整 TTL 和容量参数。缓存系统会直接影响用户体验和系统稳定性,值得持续投入关注。
缓存大小与内存占用估算
假设缓存 value 平均 1KB,缓存 10 万条记录需要约 100MB 内存。加上 map 本身的开销(每个 bucket 约 8 字节)和 entry 结构体开销,实际占用可能在 120-130MB 左右。对于小型服务这通常没问题,但缓存千万级记录时就需要仔细计算。
估算公式:
总内存 ≈ 条目数 × (Key平均大小 + Value平均大小 + entry结构体开销 + map桶开销)
跨进程缓存一致性
内存缓存的最大局限是每个进程独立。微服务架构下多个实例各有一份缓存,彼此不会同步。解决方式:
- 使用短 TTL(几秒到几十秒),接受短暂不一致。
- 缓存失效时通过消息队列广播,让其他实例也清理。
- 对一致性要求高的数据,不使用内存缓存,直接用 Redis 或数据库。
缓存预热策略
服务重启后缓存为空,大量请求会直接打到数据库。预热可以在启动时填充热点数据:
func (s *Service) Preload(ctx context.Context) error {
configs, err := s.store.ListHotConfigs(ctx)
if err != nil {
return err
}
for _, cfg := range configs {
s.cache.Set(cfg.Name, cfg, 5*time.Minute)
}
log.Printf("preloaded %d configs", len(configs))
return nil
}
预热的数量要控制,不要把全量数据都塞进缓存。只预热真正高频的 key。
缓存雪崩防护
大量缓存同时过期会导致雪崩。防御方法:
- 随机 TTL:基础 TTL 加随机偏移。
ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
过期前刷新:快过期时后台异步刷新。
singleflight:缓存 miss 时合并请求。
三种方法可以组合使用,根据业务需求选择。雪崩比穿透更危险,因为它会在短时间内制造大量 miss,直接把数据库打满。
综合对比
| 方案 | 速度 | 一致性 | 容量控制 | 部署复杂度 |
|---|---|---|---|---|
| 本文内存缓存 | 最快 | 差 | 需自实现 | 低 |
| groupcache | 很快 | 中等 | 内置LRU | 中 |
| freecache | 很快 | 差 | 固定容量 | 低 |
| Redis | 快 | 好 | 可配置 | 高 |
| Memcached | 快 | 好 | 可配置 | 高 |
本文的缓存适合理解原理和小型场景;生产环境推荐使用成熟方案。
真实项目用例
在实际团队协作中,下面是几个推荐的工作流:
代码审查清单
- 函数是否处理了所有 error 返回值
- 并发代码是否有明确的退出路径和 WaitGroup
- 用户输入是否经过校验和清洗
- 敏感配置是否通过环境变量或加密存储注入
- 测试是否覆盖了正常路径和至少一个错误路径
- 日志是否包含足够的上下文信息但不泄露敏感数据
- 接口设计是否符合最小接口原则
CI/CD 集成建议
- 每次提交前运行
go fmt ./... - CI 中运行
go vet ./...和golangci-lint run - 单元测试使用
go test -race ./...检测数据竞争 - 关键路径的 benchmark 加入回归测试
- 使用
go mod verify确保依赖完整性
性能调优检查点
- 使用 pprof 分析 CPU 和内存使用
- 关注 benchmark 的 allocs/op,减少高频路径的堆分配
- 检查数据库查询是否使用索引
- 确认外部 HTTP 调用有合理的超时设置
- 缓存热点数据,但注意缓存一致性和过期策略
面试高频考点
如果你正在准备 Go 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- sync.Mutex vs sync.RWMutex vs atomic
掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。
常见问题(FAQ)
Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。
Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。
Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。
Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。
Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 Go 官方博客,关注语言演进和最佳实践更新
参考资源
- Go 官方网站:https://go.dev/
- Go 标准库文档:https://pkg.go.dev/std
- Go by Example:https://gobyexample.com/
- Effective Go:https://go.dev/doc/effective_go
- Go 常见问题:https://go.dev/doc/faq
- Go 项目实战社区案例和开源项目源码
本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
生产环境缓存实践
在生产环境中使用内存缓存,还需要考虑以下因素:首先,监控是必不可少的。除了命中率和未命中率,还需要关注缓存大小、过期速率、清理耗时等指标。其次,缓存不应该成为单点故障。每个服务实例持有独立的缓存意味着缓存一致性由业务逻辑来保证。对于需要严格一致性的数据,应考虑使用分布式缓存。第三,缓存的更新和失效策略要配合业务周期。比如每日凌晨批量更新的报表数据,可以在更新完成后统一刷新缓存。第四,对于缓存内容较大的场景(如用户头像),建议只缓存 metadata 或 URL,把大文件交给 CDN 或对象存储。最后,定期 review 缓存内容,清理不再使用的 key 和 value,防止缓存的"僵尸数据"长期占用内存。这些都是从"能运行"到"运行得好"的必经之路。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。