• 作者:老汪软件技巧
  • 发表时间:2024-08-17 15:35
  • 浏览量:

一、golang 世界的多级缓存框架现状

golang 开源框架的世界里有很多NB的分布式缓存框架,也有很多NB的本地内存缓存框架,但是没有NB的多级缓存框架!如果你体验过阿里巴巴开源的 JetCache 缓存框架,你就会觉得其他缓存框架都不够好用。为了解决缓存高性能、高可用面临的问题,也为了提高研发效率和质量,我们决定自研 golang 版本的 JetCache 缓存框架。

Featureeko/gocachebytedance/gopkggo-redis/cachemgtv-tech/jetcache-go

Star数量

2.4k

1.7k

741

139

多级缓存

缓存旁路(loadable)

泛型支持

单飞模式

缓存更新监听器(缓存一致)

异步刷新

指标统计

Y(简单)

缓存空对象

批量查询(MGet)

稀疏列表缓存

二、JetCache-go 的魅力2.1 功能概览

2.2 缓存旁路模式(Cache-Aside Pattern)配置

redis:
  test-redis:
    addrs:
      - 1.1.1.1:6442
      - 1.1.1.2:7440
      - 1.1.1.3:7441
    type: cluster
    readTimeout: 100ms
    writeTimeout: 100ms
cache:
  graph:
    cacheType: both # 可选 both、remote、local 三种模式
    localType: tinyLFU # 可选 tinyLFU、freeCache 两种本地缓存类型
    codec: msgpack # 可选 json、sonic、msgpack 三种序列化方式
    redisName: test-redis
    refreshDuration: 1m # 自动刷新缓存周期
    stopRefreshAfterLastAccess: 5m # 缓存 key 多久没有访问后停止自动刷新
    localExpire: 1m # 本地缓存过期时长
    remoteExpire: 1h # 默认的远程缓存过期时长
  part:
    cacheType: local # 可选 both、remote、local 三种模式
    localType: freeCache # 可选 tinyLFU、freeCache 两种本地缓存类型
    codec: sonic # 可选 json、sonic、msgpack 三种序列化方式
    redisName: test-redis
    refreshDuration: 1m # 自动刷新缓存周期
    stopRefreshAfterLastAccess: 5m # 缓存 key 多久没有访问后停止自动刷新
    localExpire: 1m # 本地缓存过期时长
    remoteExpire: 1h # 默认的远程缓存过期时长

我们内部脚手架封装好了通过配置即可初始化缓存实例。

使用

Once接口查询,并开启缓存自动刷新。

// GetSubGraphCache 查询知识图谱缓存
func (s *Service) GetSubGraphCache(ctx context.Context, req *api.SubGraphReq) (resp *api.SubGraphResp, err error) {
    if err := req.Validate(); err != nil {
       return nil, err
    }
    key := model.KeySubGraph.Key(req.VertexId, req.ArtifactId, req.Depth)
    err = s.dao.CacheGraph.Once(ctx, key,
       cache.TTL(model.KeySubGraph.Expire()),
       cache.Refresh(true),
       cache.Value(&resp),
       cache.Do(func(ctx context.Context) (any, error) {
          return s.getSubGraph(ctx, req)
       }))
    return
}
// 查询知识图谱
func (s *Service) getSubGraph(ctx context.Context, req *api.SubGraphReq) (resp *api.SubGraphResp, err error) {
    // 逻辑实现
}

MGet(稀疏列表缓存)泛型接口查询

// MGetPartCache 批量获取视频缓存
func (d *Dao) MGetPartCache(ctx context.Context, partIds []int64) map[int64]*model.Part {
    return d.CachePart.MGet(ctx, model.KeyPart.Key(), partIds,
       func(ctx context.Context, ids []int64) (map[int64]*model.Part, error) {
          return d.Odin.MGetPart(ctx, ids)
       })
}
// MGetPart 批量获取分集信息
func (c *Odin) MGetPart(ctx context.Context, partIds []int64) (map[int64]*model.Part, error) {
    // 查询逻辑
}

通过缓存旁路模式,只需要简单的对业务方法进行代理,就能够叠加多级缓存的各种特效。

三、JetCache-go 是如何解决缓存中的经典问题的3.1 分布式-缓存穿透

关键词:强调缓存和数据库都没有数据+并发访问

应对策略:

jetcache-go采取轻量级的 方式来解决这个问题:

func (c *jetCache) setNotFound(ctx context.Context, key string, skipLocal bool) error {
    if c.local != nil && !skipLocal {
       c.local.Set(key, notFoundPlaceholder)
    }
    // 略...
    ttl := c.notFoundExpiry + time.Duration(c.safeRand.Int63n(int64(c.offset)))
    return c.remote.SetEX(ctx, key, notFoundPlaceholder, ttl)
}

func (c *jetCache) getBytes(ctx context.Context, key string, skipLocal bool) ([]byte, error) {
    if !skipLocal && c.local != nil {
       b, ok := c.local.Get(key)
       if ok {
          c.statsHandler.IncrHit()
          c.statsHandler.IncrLocalHit()
          if bytes.Compare(b, notFoundPlaceholder) == 0 {
             return nil, c.errNotFound
          }
          return b, nil
       }
       c.statsHandler.IncrLocalMiss()
    }
    
    // 略...
    b := util.Bytes(s)
    if bytes.Compare(b, notFoundPlaceholder) == 0 {
       return nil, c.errNotFound
    }
    // 略...
    
    return b, nil
}

3.2 分布式-缓存击穿

关键词:强调单个热点Key过期+并发访问

应对策略:

jetcache-go提供 、、、等机制实现击穿保护。

3.3 分布式-防缓存击穿利器-singleflight

在go标准库中(/x/sync/singleflight) 提供了可重复的函数调用抑制机制。通过给每次函数调用分配一个key,相同key的函数并发调用时,只会被执行一次,返回相同的结果。其本质是对函数调用的结果进行复用。

jetcache-go的 Once、MGet 接口均采用singlefilght机制实现。

3.4 分布式-防缓存击穿利器-auto refresh

mycache := cache.New(cache.WithName("any"),
    cache.WithRemote(remote.NewGoRedisV8Adaptor(ring)),
    cache.WithLocal(local.NewFreeCache(256*local.MB, time.Minute)),
    cache.WithErrNotFound(errRecordNotFound),
    cache.WithRefreshDuration(time.Minute),
    cache.WithStopRefreshAfterLastAccess(time.Hour))
)
    
if err := mycache.Once(ctx, key, cache.Value(obj), cache.TTL(time.Hour), cache.Refresh(true),
    cache.Do(func(ctx context.Context) (any, error) {
       return mockDBGetObject(1)
    })); err != nil {
    panic(err)
}   

3.5 分布式-缓存雪崩

关键词:强调批量Key过期+并发访问

应对策略:

jetcache-go提供、、等机制来避免缓存雪崩。

设置缓存数据的随机过期时间:

func (c *TinyLFU) Set(key string, b []byte) {
    ttl := c.ttl
    if c.offset > 0 {
       ttl += time.Duration(c.rand.Int63n(int64(c.offset)))
    }
    c.cache.SetWithTTL(key, b, 1, ttl)
    // wait for value to pass through buffers
    c.cache.Wait()
}

func (c *jetCache) setNotFound(ctx context.Context, key string, skipLocal bool) error {
    if c.local != nil && !skipLocal {
       c.local.Set(key, notFoundPlaceholder)
    }
    // 略...
    ttl := c.notFoundExpiry + time.Duration(c.safeRand.Int63n(int64(c.offset)))
    return c.remote.SetEX(ctx, key, notFoundPlaceholder, ttl)
}

3.6 分布式-大Key问题

关键词:强调Key数据量过大/Key中成员的数量过多

大Key是指Key本身的数据量过大或者Key中的成员数过多。大Key容易导致Redis实例的带宽利用率被占满、集群架构下分片内存不均衡、对Key进行删除操作造成长时间阻塞等问题。

应对策略:

jetcache-go支持,实现了多种序列化方案。默认是Msgpack+Snappy压缩,也支持自定义实现。

3.7 分布式-热Key问题

关键词:强调Key的请求频频过高

热Key是指预期外的访问量陡增,如突然出现的爆款商品、访问量暴涨的热点新闻、直播间某主播搞活动带来的大量刷屏点赞、游戏中某区域发生多个工会之间的战斗涉及大量玩家等。导致占用大量CPU资源、节点连接数被耗尽,影响其他请求并导致整体性能降低。

应对策略:

Jetcache-go 支持,也支持针对缓存数据利用率较高的场景开启自动刷新缓存,达到的效果。

四、JetCache-go 有哪些高级特性4.1 支持多种序列化方式codec方式说明优势

原生json

golang自带的序列化工具

兼容性强

msgpack

msgpack+snappy压缩(内容>64字节)

性能较强、容量小

sonic

字节开源的高性能json序列化工具

性能强

你也可以自定义实现codec的接口,注册进来。

4.2 支持多种本地缓存、远程缓存名称类型优缺点

TinyLFU

LOCAL

优点:缓存命中率高缺点:如果缓存条目过多,GC负担会比较严重

FreeCache

LOCAL

优点:Zero GC高性能 缺点:缓存Key/Value大小限制,需要预申请虚拟内存

redis/go-redis(v8)

REMOTE

非常先进的go Redis客户端(star 19.6k)

你也可以自定义缓存实现:

Local接口


type Local interface {
    // Set stores the given data with the specified key.
    Set(key string, data []byte)
    // Get retrieves the data associated with the specified key.
    // It returns the data and a boolean indicating whether the key was found.
    Get(key string) ([]byte, bool)
    // Del deletes the data associated with the specified key.
    Del(key string)
}

Remote接口

type Remote interface {
    // SetEX sets the expiration value for a key.
    SetEX(ctx context.Context, key string, value any, expire time.Duration) error
    // SetNX sets the value of a key if it does not already exist.
    SetNX(ctx context.Context, key string, value any, expire time.Duration) (val bool, err error)
    // SetXX sets the value of a key if it already exists.
    SetXX(ctx context.Context, key string, value any, expire time.Duration) (val bool, err error)
    // Get retrieves the value of a key. It returns errNotFound (e.g., redis.Nil) when the key does not exist.
    Get(ctx context.Context, key string) (val string, err error)
    // Del deletes the cached value associated with a key.
    Del(ctx context.Context, key string) (val int64, err error)
    // MGet retrieves the values of multiple keys.
    MGet(ctx context.Context, keys ...string) (map[string]any, error)
    // MSet sets multiple key-value pairs in the cache.
    MSet(ctx context.Context, value map[string]any, expire time.Duration) error
    // Nil returns an error indicating that the key does not exist.
    Nil() error
}

4.3 多级缓存自由组合

多级缓存是 jetcache-go 最具特色的能力,让我们可以非常方便快速的构建缓存积木,从而用最低成本提供最高的性能。

4.4 缓存自动刷新

缓存刷新配置及使用示例:

mycache := cache.New(cache.WithName("any"),
   // ...
   // cache.WithRefreshDuration sets the asynchronous refresh interval
   cache.WithRefreshDuration(time.Minute),
   // cache.WithStopRefreshAfterLastAccess sets the time to cancel the refresh task after the cache key is not accessed
   cache.WithStopRefreshAfterLastAccess(time.Hour))
// `Once` interface starts automatic refresh by `cache.Refresh(true)`
err := mycache.Once(ctx, key, cache.Value(obj), cache.Refresh(true), cache.Do(func(ctx context.Context) (any, error) {
    return mockDBGetObject(1)
}))

缓存自动刷新原理

4.5 稀疏列表缓存实现(MGet)

普通查询逻辑

MGet查询逻辑

MGet 通过 golang的泛型机制 + Load 函数,非常友好的多级缓存批量查询ID对应的实体。如果缓存是redis或者多级缓存最后一级是redis,查询时采用Pipeline实现读写操作,提升性能。查询未命中本地缓存,需要去查询Redis和DB时,会对Key排序,并采用单飞模式(singleflight)调用。需要说明是,针对异常场景(IO异常、序列化异常等),我们设计思路是尽可能提供有损服务,防止穿透。

4.6 缓存更新监听器-解决缓存一致性问题

缓存更新监听器,支持拓展缓存更新后所有GO进程的本地缓存失效,实现本地缓存一致性,避免数据波动。JetCache-Go抽象了5种事件:Set、SetByOnce、SetByRefresh、SetByMGet、Delete。

缓存监听广播流程

通过 redis 的pub/sub实现缓存广播示例:

当然,你也可以选择通过其他消息队列组件去实现,事件和方法我们都提供了。

4.7 缓存高可用 - 自动降级

4.8 缓存指标统计

支持实现stats.Handler接口并注册到Cache组件来自定义收集指标,例如使用Prometheus 采集指标。

我们默认实现了通过日志打印统计指标,如下所示:

2023/09/11 16:42:30.695294 statslogger.go:178: [INFO] jetcache-go stats last 1m0s.
cache       |         qpm|   hit_ratio|         hit|        miss|       query|  query_fail
------------+------------+------------+------------+------------+------------+------------
bench       |   216440123|     100.00%|   216439867|         256|         256|           0|
bench_local |   216440123|     100.00%|   216434970|        5153|           -|           -|
bench_remote|        5153|      95.03%|        4897|         256|           -|           -|
------------+------------+------------+------------+------------+------------+------------

实现stats.Handler接口并将指标采集到Prometheus。

统计维度为 Cache 实例对应的名称,建议��考上文的 graph、part 设置不同的缓存实例,就会有不同的缓存统计指标。

五、总结

JetCache-go 实现了核心功能跟阿里巴巴开源的 JetCache 缓存框架对齐,并在公司内部大规模推广,也接入了很多重要业务。最显著的效果是:提高了研发效率,也提升了服务的性和稳定性。

JetCache-go 虽然取得了阶段性的成绩,但是,仍然存在一些不足:

我们会持续的迭代,也希望更多的小伙伴一起参与共建!