基于约束大于规范的想法,封装缓存组件

架构?何谓架构?好像并没有一个准确的概念。以前我觉得架构就是搭出一套完美的框架,可以让其他开发人员减少不必要的代码开发量;可以完美地实现高内聚低耦合的准则;可以尽可能地实现用最少的硬件资源,实现最高的程序效率......事实上,架构也并非只是追求这些。因为,程序是人写出来的,所以,似乎架构更多的需要考虑人这个因素。

我们发现,即便我们在程序设计之初定了诸多规范,到了实际开发过程中,由于种种原因,规范并没有按照我们预想的情况落实。这个时候,我的心里突然有一个声音:约束大于规范冒了出来。但是,约束同样会带来一些问题,比如,牺牲了一些性能,比如,带了一定的学习成本。但是,似乎一旦约束形成,会在后续业务不断发展中带来便利。

架构师似乎总是在不断地做抉择。我想,架构师心里一定有一个声音:世间安得两全法,不负如来不负卿。

Cache接口设计的想法

基于约束大于规范的想法,我们有了如下一些约束:

第一、把业务中常用到的缓存的方法集合通过接口的方式进行约束。

第二、基于缓存采用cache aside模式。

  • 读数据时,先读缓存,如果有就返回。没有再读数据源,将数据放到缓存

  • 写数据时,先写数据源,然后让缓存失效

我们把这个规范进行封装,以达到约束的目的。

基于上述的约束,我们进行了如下的封装:

Go 复制代码
package cache

import (
	"context"
	"time"
)

type Cache interface {
	// 删除缓存
	// 先删除数据库数据,再删除缓存数据
	DelCtx(ctx context.Context, query func() error, keys ...string) error
	// 根据key获取缓存,如果缓存不存在,
	// 通过query方法从数据库获取缓存并设置缓存,使用默认的失效时间
	TakeCtx(ctx context.Context, key string, query func() (interface{}, error)) ([]byte, error)
	// 根据key获取缓存,如果缓存不存在,
	// 通过query方法从数据库获取缓存并设置缓存
	TakeWithExpireCtx(ctx context.Context, key string, expire time.Duration, query func() (interface{}, error)) ([]byte, error)
}

细心的朋友可能已经发现,这个接口中的方法集合中都包含了一个函数传参。为什么要有这样一个传参呢?首先,在go中函数是一等公民,其地位和其他数据类型一样,都可以做为函数的参数。这个特点使我们的封装更方便。因为,我需要把数据库的操作封装到我的方法中,以达到约束的目的。关于函数式编程,我在另一篇文章中《golang函数式编程》有写过,不过,我尚有部分原理还没有搞清楚,还需要找时间继续探究。

函数一等公民这个特点,似乎很好理解,但是,进一步思考,我们可能会想到,数据库操作,入参不是固定的啊,这个要怎么处理呢?很好的问题。事实上,我们可以利用闭包的特点,把这些不是固定的入参传到函数内部。

基于redis实现缓存的想法

主要就是考虑缓存雪崩,缓存穿透等问题,其中,缓存雪崩和缓存穿透的设计参考了go-zero项目中的设计,我在go-zero设计思想的基础上进行了封装。

Go 复制代码
package cache

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/zeromicro/go-zero/core/mathx"
	"github.com/zeromicro/go-zero/core/syncx"
	"gorm.io/gorm/logger"
)

const (
	notFoundPlaceholder = "*" //数据库没有查询到记录时,缓存值设置为*,避免缓存穿透
	// make the expiry unstable to avoid lots of cached items expire at the same time
	// make the unstable expiry to be [0.95, 1.05] * seconds
	expiryDeviation = 0.05
)

// indicates there is no such value associate with the key
var errPlaceholder = errors.New("placeholder")
var ErrNotFound = errors.New("not found")

// ErrRecordNotFound record not found error
var ErrRecordNotFound = errors.New("record not found") //数据库没有查询到记录时,返回该错误

type RedisCache struct {
	rds            *redis.Client
	expiry         time.Duration //缓存失效时间
	notFoundExpiry time.Duration //数据库没有查询到记录时,缓存失效时间
	logger         logger.Interface
	barrier        syncx.SingleFlight //允许具有相同键的并发调用共享调用结果
	unstableExpiry mathx.Unstable     //避免缓存雪崩,失效时间随机值
}

func NewRedisCache(rds *redis.Client, log logger.Interface, barrier syncx.SingleFlight, opts ...Option) *RedisCache {
	if log == nil {
		log = logger.Default.LogMode(logger.Info)
	}
	o := newOptions(opts...)
	return &RedisCache{
		rds:            rds,
		expiry:         o.Expiry,
		notFoundExpiry: o.NotFoundExpiry,
		logger:         log,
		barrier:        barrier,
		unstableExpiry: mathx.NewUnstable(expiryDeviation),
	}
}

func (r *RedisCache) DelCtx(ctx context.Context, query func() error, keys ...string) error {
	if err := query(); err != nil {
		r.logger.Error(ctx, fmt.Sprintf("Failed to query: %v", err))
		return err
	}
	for _, key := range keys {
		if err := r.rds.Del(ctx, key).Err(); err != nil {
			r.logger.Error(ctx, fmt.Sprintf("Failed to delete key %s: %v", key, err))
			//TODO 起个定时任务异步重试
		}
	}
	return nil
}

func (r *RedisCache) TakeCtx(ctx context.Context, key string, query func() (interface{}, error)) ([]byte, error) {
	return r.TakeWithExpireCtx(ctx, key, r.expiry, query)
}

func (r *RedisCache) TakeWithExpireCtx(ctx context.Context, key string, expire time.Duration, query func() (interface{}, error)) ([]byte, error) {
	// 在过期时间的基础上,增加一个随机值,避免缓存雪崩
	expire = r.aroundDuration(expire)
	// 并发控制,同一个key的请求,只有一个请求执行,其他请求等待共享结果
	res, err := r.barrier.Do(key, func() (interface{}, error) {
		cacheVal, err := r.doGetCache(ctx, key)
		if err != nil {
			// 如果缓存中查到的是notfound的占位符,直接返回
			if errors.Is(err, errPlaceholder) {
				return nil, ErrNotFound
			} else if !errors.Is(err, ErrNotFound) {
				return nil, err
			}
		}

		// 缓存中存在值,直接返回
		if len(cacheVal) > 0 {
			return cacheVal, nil
		}
		data, err := query()
		if errors.Is(err, ErrRecordNotFound) {
			//数据库中不存在该值,则将占位符缓存到redis
			if err := r.setCacheWithNotFound(ctx, key); err != nil {
				r.logger.Error(ctx, fmt.Sprintf("Failed to set not found key %s: %v", key, err))
			}
			return nil, ErrNotFound
		} else if err != nil {
			return nil, err
		}
		cacheVal, err = json.Marshal(data)
		if err != nil {
			return nil, err
		}
		if err := r.rds.Set(ctx, key, cacheVal, expire).Err(); err != nil {
			r.logger.Error(ctx, fmt.Sprintf("Failed to set key %s: %v", key, err))
			return nil, err
		}

		return cacheVal, nil
	})
	if err != nil {
		return []byte{}, err
	}
	//断言为[]byte
	val, ok := res.([]byte)
	if !ok {
		return []byte{}, fmt.Errorf("failed to convert value to bytes")
	}
	return val, nil
}

func (r *RedisCache) aroundDuration(duration time.Duration) time.Duration {
	return r.unstableExpiry.AroundDuration(duration)
}

// 获取缓存
func (r *RedisCache) doGetCache(ctx context.Context, key string) ([]byte, error) {
	val, err := r.rds.Get(ctx, key).Bytes()
	if err != nil {
		if err == redis.Nil {
			return nil, ErrNotFound
		}
		return nil, err
	}
	if len(val) == 0 {
		return nil, ErrNotFound
	}
	// 如果缓存的值为notfound的占位符,则表示数据库中不存在该值,避免再次查询数据库,避免缓存穿透
	if string(val) == notFoundPlaceholder {
		return nil, errPlaceholder
	}
	return val, nil
}

// 数据库没有查询到值,则设置占位符,避免缓存穿透
func (r *RedisCache) setCacheWithNotFound(ctx context.Context, key string) error {
	notFoundExpiry := r.aroundDuration(r.notFoundExpiry)
	if err := r.rds.Set(ctx, key, notFoundPlaceholder, notFoundExpiry).Err(); err != nil {
		r.logger.Error(ctx, fmt.Sprintf("Failed to set not found key %s: %v", key, err))
		return err
	}
	return nil
}
Go 复制代码
package cache

import "time"

const (
	defaultExpiry         = time.Hour * 24 * 7
	defaultNotFoundExpiry = time.Minute
)

type (
	// Options is used to store the cache options.
	Options struct {
		Expiry         time.Duration
		NotFoundExpiry time.Duration
	}

	// Option defines the method to customize an Options.
	Option func(o *Options)
)

func newOptions(opts ...Option) Options {
	var o Options
	for _, opt := range opts {
		opt(&o)
	}

	if o.Expiry <= 0 {
		o.Expiry = defaultExpiry
	}
	if o.NotFoundExpiry <= 0 {
		o.NotFoundExpiry = defaultNotFoundExpiry
	}

	return o
}

// WithExpiry returns a func to customize an Options with given expiry.
func WithExpiry(expiry time.Duration) Option {
	return func(o *Options) {
		o.Expiry = expiry
	}
}

// WithNotFoundExpiry returns a func to customize an Options with given not found expiry.
func WithNotFoundExpiry(expiry time.Duration) Option {
	return func(o *Options) {
		o.NotFoundExpiry = expiry
	}
}

最后,附上部分测试用例,数据库操作的逻辑,我没有写,通过模拟的方式实现。

Go 复制代码
package cache

import (
	"context"
	"testing"

	"github.com/redis/go-redis/v9"
	"github.com/zeromicro/go-zero/core/syncx"
	"gorm.io/gorm/logger"
)

func TestRedisCache(t *testing.T) {
	rdb := redis.NewClient(&redis.Options{
		Addr:     "", // Redis地址
		Password: "",       // 密码(无密码则为空)
		DB:       11,                  // 使用默认DB
	})

	ctx := context.Background()
	rc := NewRedisCache(rdb, logger.Default.LogMode(logger.Info), syncx.NewSingleFlight())

	// 测试 TakeCtx 方法
	key := "testKey"
	queryVal := "hello, world"
	// 通过闭包的方式,模拟查询数据库的操作
	query := func() (interface{}, error) {
		return queryVal, nil
	}
	val, err := rc.TakeCtx(ctx, key, query)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	t.Log("return query func val:", string(val))

	// 再次调用 TakeCtx 方法,应该返回缓存的值
	queryVal = "this should not be returned"
	val, err = rc.TakeCtx(ctx, key, query)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	t.Log("cache val:", string(val))
	// 测试 DelCtx 方法
	if err := rc.DelCtx(ctx, func() error {
		t.Log("mock query before delete")
		return nil
	}, key); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	queryVal = "this should be cached"
	// 验证键是否已被删除
	val, err = rc.TakeCtx(ctx, key, query)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if string(val) != "this should be cached" {
		t.Fatalf("unexpected value: %s", string(val))
	}
}

这篇文章就写到这里结束了。水平有限,有写的不对的地方,还望广大网友斧正,不胜感激。

相关推荐
fat house cat_2 小时前
【redis】线程IO模型
java·redis
敖云岚3 小时前
【Redis】分布式锁的介绍与演进之路
数据库·redis·分布式
让我上个超影吧5 小时前
黑马点评【基于redis实现共享session登录】
java·redis
懒羊羊大王呀8 小时前
Ubuntu20.04中 Redis 的安装和配置
linux·redis
John Song10 小时前
Redis 集群批量删除key报错 CROSSSLOT Keys in request don‘t hash to the same slot
数据库·redis·哈希算法
Zfox_19 小时前
Redis:Hash数据类型
服务器·数据库·redis·缓存·微服务·哈希算法
呼拉拉呼拉19 小时前
Redis内存淘汰策略
redis·缓存
咖啡啡不加糖1 天前
Redis大key产生、排查与优化实践
java·数据库·redis·后端·缓存
MickeyCV1 天前
使用Docker部署MySQL&Redis容器与常见命令
redis·mysql·docker·容器·wsl·镜像
肥仔哥哥19301 天前
springCloud2025+springBoot3.5.0+Nacos集成redis从nacos拉配置起服务
redis·缓存·最新boot3集成