实现一个进程内缓存

5.3 实现一个进程内缓存

上一节讲解了常用的缓存算法和实现,但它们都是并发不安全的。本节我们基于前面的缓存淘汰算法,创建一个并发安全的进程内缓存库。

5.3.1 支持并发读写

我们通过 sync.RWMutex 来封装读写方法,使缓存支持并发读写。在上节提到的 Cache 接口定义文件中加上如下代码。

// DefaultMaxBytes 默认允许占用的最大内存
const DefaultMaxBytes = 1 << 29

// safeCache 并发安全缓存
type safeCache struct {
	m     sync.RWMutex
	cache Cache

	nhit, nget int
}

func newSafeCache(cache Cache) *safeCache {
	return &safeCache{
		cache: cache,
	}
}

func (sc *safeCache) set(key string, value interface{}) {
	sc.m.Lock()
	defer sc.m.Unlock()
	sc.cache.Set(key, value)
}

func (sc *safeCache) get(key string) interface{} {
	sc.m.RLock()
	defer sc.m.RUnlock()
	sc.nget++
	if sc.cache == nil {
		return nil
	}

	v := sc.cache.Get(key)
	if v != nil {
		log.Println("[TourCache] hit")
		sc.nhit++
	}

	return v
}


func (sc *safeCache) stat() *Stat {
	sc.m.RLock()
	defer sc.m.RUnlock()
	return &Stat{
		NHit: sc.nhit,
		NGet: sc.nget,
	}
}

type Stat struct {
	NHit, NGet int
}
  • 并发安全的 cache 实现很简单,构造函数接收一个实现了 Cache 接口的淘汰算法实现;
  • nget, nhit 记录缓存获取次数和命中次数,并定义 Stat 类型和 stat 方法,方便查看统计数据;
  • 在前面的实现中,保证 value 是 nil 的值不会缓存,因此可以通过 value 是否为 nil 来判断是否命中缓存,而不是使用另外一个返回值;
  • sc.cache == nil 时,没有创建一个默认的 Cache 实现是避免循环引用,因为前面实现的淘汰算法构造函数都返回了 Cache 接口类型,引用了 github.com/go-programming-tour-book/cache 包;这个问题可以不解决,因为 safeCache 是未导出的,在使用它的地方,我们可以确保其中的 cache 字段一定非 nil。

5.3.2 缓存库主体结构 TourCache

有了并发读写安全的 safeCache,接下来提供一个给客户端使用的接口。一般来说,缓存的流程如下:

从上图可以看出,缓存只对外提供 Get 接口(其他的都供内部使用)。命中缓存,直接返回缓存中的数据;在缓存未命中时,从 DB 中获取数据(这里的 DB 泛指一切数据源),写入缓存,并返回数据。

5.3.2.1 Getter 接口

为了更方便通用化从数据库获取数据(因为可能不同的来源),我们在 cache 根目录创建一个 tour_cache.go 文件,定义一个接口 Getter:

type Getter interface {
	Get(key string) interface{}
}

数据源只要实现该接口,也就是提供 Get(key string) interface{} 方法就可以被缓存使用。

为了方便使用,学习 Go 中的一个通用设计思路,为该接口提供一个默认的实现:

type GetFunc func(key string) interface{}

func (f GetFunc) Get(key string) interface{} {
	return f(key)
}

这样任意一个函数,只要签名和 Get(key string) interface{} 一致,通过转为 GetFunc 类型,就实现了 Getter 接口。

记得 net/http 包中的 Handler 接口和 HandleFunc 类型吗?

5.3.2.2 TourCache

在 tour_cache.go 中定义我们对外唯一的缓存功能的结构:

type TourCache struct {
	mainCache *safeCache
	getter    Getter
}

func NewTourCache(getter Getter, cache Cache) *TourCache {
	return &TourCache{
		mainCache: newSafeCache(cache),
		getter:    getter,
	}
}

func (t *TourCache) Get(key string) interface{} {
	val := t.mainCache.get(key)
	if val != nil {
		return val
	}

	if t.getter != nil {
		val = t.getter.Get(key)
		if val == nil {
			return nil
		}
		t.mainCache.set(key, val)
		return val
	}

	return nil
}
  • TourCache 结构体包含两个字段,mainCache 即是并发安全的缓存实现;getter 是回调,用于缓存未命中时从数据源获取数据;
  • Get 方法:先从缓存获取数据,如果不存在再调用回调函数获取数据,并将数据写入缓存,最后返回获取的数据;

为了方便统计,在 safeCache 结构中,我们定义了 nget 和 nhit,用来记录缓存获取次数和命中次数。我们为 TourCache 提供统计方法:

func (t *TourCache) Stat() *Stat {
	return t.mainCache.stat()
}

5.3.3 测试

至此我们实现了一个并发安全的缓存库。最后通过一个测试用例验证我们的缓存库,同时看看如何使用该缓存库。

在项目根目录新增一个 tour_cache_test.go 测试文件,增加单元测试。

func TestTourCacheGet(t *testing.T) {
	db := map[string]string{
		"key1": "val1",
		"key2": "val2",
		"key3": "val3",
		"key4": "val4",
	}
	getter := cache.GetFunc(func(key string) interface{} {
		log.Println("[From DB] find key", key)

		if val, ok := db[key]; ok {
			return val
		}
		return nil
	})
	tourCache := cache.NewTourCache(getter, lru.New(0, nil))

	is := is.New(t)

	var wg sync.WaitGroup

	for k, v := range db {
		wg.Add(1)
		go func(k, v string) {
			defer wg.Done()
			is.Equal(tourCache.Get(k), v)

			is.Equal(tourCache.Get(k), v)
		}(k, v)
	}
	wg.Wait()

	is.Equal(tourCache.Get("unknown"), nil)
	is.Equal(tourCache.Get("unknown"), nil)

	is.Equal(tourCache.Stat().NGet, 10)
	is.Equal(tourCache.Stat().NHit, 4)
}
  • 用一个 map 模拟耗时的数据库;
  • 回调函数简单的从 map 中获取数据,并记录日志;
  • 通过 lru 算法构造一个 TourCache 实例;
  • 并发的从缓存获取数据:在一个 goroutine 中,对同一个 key 获取两次,尽可能保证有命中缓存的情况;
  • 通过一个不存在的 key 来验证这种情况是否会异常;
  • 最后验证获取次数和命中次数;

测试结果如下:

$ go test -run TestTourCacheGet
2020/03/21 10:56:42 [From DB] find key key2
2020/03/21 10:56:42 [TourCache] hit
2020/03/21 10:56:42 [From DB] find key key4
2020/03/21 10:56:42 [TourCache] hit
2020/03/21 10:56:42 [From DB] find key key3
2020/03/21 10:56:42 [TourCache] hit
2020/03/21 10:56:42 [From DB] find key key1
2020/03/21 10:56:42 [TourCache] hit
2020/03/21 10:56:42 [From DB] find key unknown
2020/03/21 10:56:42 [From DB] find key unknown
PASS
ok  	github.com/polaris1119/cache	0.173s

可以很清晰地看到,缓存为空时,调用了回调函数,获取数据,第二次访问时,则直接从缓存中读取。



本图书由 煎鱼 ©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。