5.3 实现一个进程内缓存

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

5.3.1 支持并发读写

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

  1. // DefaultMaxBytes 默认允许占用的最大内存
  2. const DefaultMaxBytes = 1 << 29
  3. // safeCache 并发安全缓存
  4. type safeCache struct {
  5. m sync.RWMutex
  6. cache Cache
  7. nhit, nget int
  8. }
  9. func newSafeCache(cache Cache) *safeCache {
  10. return &safeCache{
  11. cache: cache,
  12. }
  13. }
  14. func (sc *safeCache) set(key string, value interface{}) {
  15. sc.m.Lock()
  16. defer sc.m.Unlock()
  17. sc.cache.Set(key, value)
  18. }
  19. func (sc *safeCache) get(key string) interface{} {
  20. sc.m.RLock()
  21. defer sc.m.RUnlock()
  22. sc.nget++
  23. if sc.cache == nil {
  24. return nil
  25. }
  26. v := sc.cache.Get(key)
  27. if v != nil {
  28. log.Println("[TourCache] hit")
  29. sc.nhit++
  30. }
  31. return v
  32. }
  33. func (sc *safeCache) stat() *Stat {
  34. sc.m.RLock()
  35. defer sc.m.RUnlock()
  36. return &Stat{
  37. NHit: sc.nhit,
  38. NGet: sc.nget,
  39. }
  40. }
  41. type Stat struct {
  42. NHit, NGet int
  43. }
  • 并发安全的 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,接下来提供一个给客户端使用的接口。一般来说,缓存的流程如下:

5.3 实现一个进程内缓存 - 图1

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

5.3.2.1 Getter 接口

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

  1. type Getter interface {
  2. Get(key string) interface{}
  3. }

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

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

  1. type GetFunc func(key string) interface{}
  2. func (f GetFunc) Get(key string) interface{} {
  3. return f(key)
  4. }

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

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

5.3.2.2 TourCache

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

  1. type TourCache struct {
  2. mainCache *safeCache
  3. getter Getter
  4. }
  5. func NewTourCache(getter Getter, cache Cache) *TourCache {
  6. return &TourCache{
  7. mainCache: newSafeCache(cache),
  8. getter: getter,
  9. }
  10. }
  11. func (t *TourCache) Get(key string) interface{} {
  12. val := t.mainCache.get(key)
  13. if val != nil {
  14. return val
  15. }
  16. if t.getter != nil {
  17. val = t.getter.Get(key)
  18. if val == nil {
  19. return nil
  20. }
  21. t.mainCache.set(key, val)
  22. return val
  23. }
  24. return nil
  25. }
  • TourCache 结构体包含两个字段,mainCache 即是并发安全的缓存实现;getter 是回调,用于缓存未命中时从数据源获取数据;
  • Get 方法:先从缓存获取数据,如果不存在再调用回调函数获取数据,并将数据写入缓存,最后返回获取的数据;

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

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

5.3.3 测试

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

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

  1. func TestTourCacheGet(t *testing.T) {
  2. db := map[string]string{
  3. "key1": "val1",
  4. "key2": "val2",
  5. "key3": "val3",
  6. "key4": "val4",
  7. }
  8. getter := cache.GetFunc(func(key string) interface{} {
  9. log.Println("[From DB] find key", key)
  10. if val, ok := db[key]; ok {
  11. return val
  12. }
  13. return nil
  14. })
  15. tourCache := cache.NewTourCache(getter, lru.New(0, nil))
  16. is := is.New(t)
  17. var wg sync.WaitGroup
  18. for k, v := range db {
  19. wg.Add(1)
  20. go func(k, v string) {
  21. defer wg.Done()
  22. is.Equal(tourCache.Get(k), v)
  23. is.Equal(tourCache.Get(k), v)
  24. }(k, v)
  25. }
  26. wg.Wait()
  27. is.Equal(tourCache.Get("unknown"), nil)
  28. is.Equal(tourCache.Get("unknown"), nil)
  29. is.Equal(tourCache.Stat().NGet, 10)
  30. is.Equal(tourCache.Stat().NHit, 4)
  31. }
  • 用一个 map 模拟耗时的数据库;
  • 回调函数简单的从 map 中获取数据,并记录日志;
  • 通过 lru 算法构造一个 TourCache 实例;
  • 并发的从缓存获取数据:在一个 goroutine 中,对同一个 key 获取两次,尽可能保证有命中缓存的情况;
  • 通过一个不存在的 key 来验证这种情况是否会异常;
  • 最后验证获取次数和命中次数;

测试结果如下:

  1. $ go test -run TestTourCacheGet
  2. 2020/03/21 10:56:42 [From DB] find key key2
  3. 2020/03/21 10:56:42 [TourCache] hit
  4. 2020/03/21 10:56:42 [From DB] find key key4
  5. 2020/03/21 10:56:42 [TourCache] hit
  6. 2020/03/21 10:56:42 [From DB] find key key3
  7. 2020/03/21 10:56:42 [TourCache] hit
  8. 2020/03/21 10:56:42 [From DB] find key key1
  9. 2020/03/21 10:56:42 [TourCache] hit
  10. 2020/03/21 10:56:42 [From DB] find key unknown
  11. 2020/03/21 10:56:42 [From DB] find key unknown
  12. PASS
  13. ok github.com/polaris1119/cache 0.173s

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

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

5.3 实现一个进程内缓存 - 图2