Skip to content

缓存

mcache 是 Maltose 框架提供的一个高层次、通用的缓存组件。它通过适配器模式(Adapter Pattern)将底层具体的缓存实现(如内存、Redis)与上层缓存接口解耦,使得开发者可以用一套统一的 API 来操作不同的缓存介质。

特性

  • 统一的 API: 无论是使用内存还是 Redis,缓存操作的 API 完全一致。
  • 适配器模式: 支持通过适配器轻松切换或扩展缓存实现,默认提供高性能的内存适配器。
  • LRU 淘汰策略: 默认的内存适配器支持基于 LRU (Least Recently Used) 策略的容量限制和自动淘汰。
  • 丰富接口: 提供了 Get, Set, GetOrSet, SetIfNotExist, Remove 等一系列丰富的缓存操作接口。
  • 包级方法: 提供了包级别的便捷方法,底层使用一个默认的缓存实例,让简单使用场景的调用更加方便。
  • 线程安全: 内置的内存适配器是线程安全的。

快速上手

mcache 提供了包级别的函数,可以直接使用,默认使用内存作为缓存。

go
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/graingo/maltose/os/mcache"
)

func main() {
    ctx := context.Background()

    // 设置一个缓存,有效期 5 秒
    err := mcache.Set(ctx, "my-key", "my-value", 5*time.Second)
    if err != nil {
        panic(err)
    }
    fmt.Println("Set cache: my-key = my-value")

    // 获取缓存
    val, _ := mcache.Get(ctx, "my-key")
    fmt.Println("Get cache:", val.String()) // 输出: my-value

    // 5秒后再次获取,此时缓存已过期
    time.Sleep(6 * time.Second)
    val, _ = mcache.Get(ctx, "my-key")
    fmt.Println("Get after 6s:", val == nil) // 输出: true
}

实例与适配器

除了包级方法,您也可以创建自己的缓存实例,这在需要多个缓存或自定义缓存行为时非常有用。

使用内存缓存 (带 LRU)

您可以创建一个有容量限制的内存缓存。当缓存项数量超过容量时,最久未被使用的项将会被自动淘汰。

go
// 创建一个新的内存缓存实例,容量为 1000
cache := mcache.New(1000)

// 使用这个实例进行操作
cache.Set(ctx, "key1", "value1", 0) // 0 表示永不过期

使用 Redis 缓存

Maltose 在 contrib/cache/redis 中提供了 Redis 适配器。您可以为缓存单独创建一个 mredis 实例,以获得更好的资源隔离。

go
import (
    "context"
    "fmt"
    "time"

    "github.com/graingo/maltose/contrib/cache/redis"
    "github.com/graingo/maltose/database/mredis"
    "github.com/graingo/maltose/os/mcache"
)

func main() {
    ctx := context.Background()

    // 1. 为缓存单独创建一个 Redis 客户端,使用独立的 db,避免与其他业务混淆
    redisConfig := &mredis.Config{
        Address: "127.0.0.1:6379",
        DB:      1, // 推荐为缓存使用独立的 db
    }
    redisClient, err := mredis.New(redisConfig)
    if err != nil {
        panic(err)
    }

    // 2. 使用 redis 实例创建 Redis 缓存适配器
    redisAdapter := redis.NewAdapterRedisWithOptions(
        redisClient,
        redis.WithKeyPrefix("checkout:cache:"),
        redis.WithLockTTL(15*time.Second),
        redis.WithLockWaitTimeout(5*time.Second),
    )

    // 3. 使用适配器创建缓存实例
    redisCache := mcache.NewWithAdapter(redisAdapter)

    // 4. 后续使用 API 与内存缓存完全一致
    redisCache.Set(ctx, "user:1", `{"name":"maltose"}`, time.Minute)
    user, _ := redisCache.Get(ctx, "user:1")
    fmt.Println(user.String())
}

关于缓存锁

GetOrSetFuncLockSetIfNotExistFuncLock 使用带随机所有者 token 的 Redis 锁,释放时通过 Lua 脚本校验所有权,避免旧持有者误删已经过期并被其他请求重新获取的锁。

  • WithLockTTL:锁的有效期,默认 10s;应大于受保护函数的正常执行时间。
  • WithLockRetryInterval:锁被占用时的重试间隔,默认 50ms
  • WithLockWaitTimeout:等待锁或缓存值的最长时间,默认 10s
  • 等待过程会响应传入的 context.Context 取消。

注意事项

使用命名空间隔离批量操作

WithKeyPrefix 会给当前 Adapter 的数据键和锁键统一增加前缀。调用方仍然使用逻辑 key,例如 user:1;Redis 中实际保存的是 checkout:cache:user:1

设置前缀后,Clear()Size()Data()Keys()Values() 只处理这个命名空间,Keys()Data() 返回的仍是去掉前缀后的逻辑 key。Clear() 使用 SCAN 和分批 UNLINK,不会清除同一 DB 中其他业务的键。

不设置前缀是为了兼容旧行为:批量读取会遍历当前 DB,Clear() 会执行 FLUSHDB。生产环境建议始终设置稳定、带分隔符的前缀。

Data()Keys()Values() 和带前缀的 Size()Clear() 都依赖增量 SCAN。它们不会通过单条 KEYS * 阻塞 Redis,但遍历大型命名空间仍会产生额外网络和内存开销。

建议为缓存使用独立的 DB

前缀可以降低误清理风险,但不能替代资源隔离。重要业务仍建议使用独立 Redis DB 或独立实例,并为不同应用分配不同前缀。

核心接口 Adapter

mcache 的灵活性来自于它的 Adapter 接口。任何实现了该接口的结构体,都可以作为 mcache 的底层驱动。

go
// Adapter 接口定义 (部分)
type Adapter interface {
	Set(ctx context.Context, key string, value interface{}, duration time.Duration) error
	Get(ctx context.Context, key string) (*mvar.Var, error)
	Remove(ctx context.Context, keys ...string) (lastValue *mvar.Var, err error)
	Close(ctx context.Context) error
    // ... 其他方法
}

这使得您可以非常轻松地实现自己的缓存适配器,例如对接 Memcached 或其他第三方缓存服务。

常用方法

除了基础的 GetSet 方法,mcache 还提供了更丰富的缓存操作接口:

Contains - 检查键是否存在

go
exists, err := cache.Contains(ctx, "user:123")
if exists {
    fmt.Println("缓存存在")
}

Update - 更新现有键的值

更新缓存值,但不改变过期时间:

go
// 只更新值,保持原有的过期时间
oldValue, existed, err := cache.Update(ctx, "user:123", newUserData)
// existed 为 false 表示原键不存在;oldValue 是更新前的值

UpdateExpire - 更新过期时间

只更新过期时间,不改变缓存值:

go
// 将过期时间延长到 1 小时
oldTTL, err := cache.UpdateExpire(ctx, "user:123", time.Hour)
// oldTTL 是修改前的剩余有效时间

GetExpire - 获取剩余过期时间

go
// 获取缓存的剩余有效时间
ttl, err := cache.GetExpire(ctx, "user:123")
if err != nil {
    // 处理错误
}
fmt.Printf("缓存还有 %v 过期\n", ttl)

防止缓存击穿

缓存击穿是指热点数据过期时,大量并发请求同时访问数据库的问题。mcache 提供了专门的方法来处理这种场景。

SetIfNotExistFunc - 不存在时计算并设置

如果缓存不存在,则执行函数加载数据并尝试设置缓存。返回值表示本次调用是否成功写入,不是缓存内容:

go
created, err := cache.SetIfNotExistFunc(ctx, "user:123",
    func(ctx context.Context) (interface{}, error) {
        // 从数据库加载数据
        user, err := db.GetUser(ctx, 123)
        if err != nil {
            return nil, err
        }
        return user, nil
    },
    time.Hour, // 缓存 1 小时
)

GetOrSetFuncLock - 加锁获取或设置

需要直接取得缓存值并避免并发重复加载时,使用 GetOrSetFuncLock

go
value, err := cache.GetOrSetFuncLock(ctx, "user:123",
    func(ctx context.Context) (interface{}, error) {
        // 从数据库加载数据
        return db.GetUser(ctx, 123)
    },
    time.Hour,
)

工作原理

  1. 第一个请求发现缓存不存在,获取锁并执行加载函数
  2. 后续并发请求会等待第一个请求完成
  3. 第一个请求完成后,其他请求读取并返回同一个缓存结果
  4. 只有一次数据库查询,避免了缓存击穿

SetIfNotExistFuncLock 的返回值仍然只是 created bool。内存与 Redis 适配器都保证加载函数在锁内执行;Redis 适配器在锁已被其他实例持有时会直接返回 false,不会返回对方计算出的值。

singleflight 的配合使用

对于更复杂的请求合并场景,可以直接配合 golang.org/x/sync/singleflight 使用:

go
import (
    "golang.org/x/sync/singleflight"

    "github.com/graingo/maltose/os/mcache"
)

var sf singleflight.Group

value, err, _ := sf.Do("user:123", func() (any, error) {
    // 1. 先尝试从缓存获取
    cached, err := cache.Get(ctx, "user:123")
    if err == nil && cached != nil {
        return cached.Interface(), nil
    }

    // 2. 缓存不存在,从数据库加载
    user, err := db.GetUser(ctx, 123)
    if err != nil {
        return nil, err
    }

    // 3. 写入缓存
    cache.Set(ctx, "user:123", user, time.Hour)
    return user, nil
})

这种方式适合在进程内避免缓存击穿。如果需要跨实例协调,则应结合 Redis 锁或专门的分布式组件设计。

Released under the MIT License.