南宁电子商务网站建设,p2p网站建设cms,环境设计专业必看网站,济南全屋定制品牌0.对原教程的一些见解
个人认为原教程中两点知识的引入不够友好。
首先是只读数据结构 ByteView 的引入使用是有点迷茫的#xff0c;可能不能很好理解为什么需要ByteView。
第二是主体结构 Group的引入也疑惑。其实要是熟悉groupcache#xff0c;那对结构Group的使用是清晰…0.对原教程的一些见解
个人认为原教程中两点知识的引入不够友好。
首先是只读数据结构 ByteView 的引入使用是有点迷茫的可能不能很好理解为什么需要ByteView。
第二是主体结构 Group的引入也疑惑。其实要是熟悉groupcache那对结构Group的使用是清晰明白的。而看该教程的人可能是没有了解过groupcache,直接就引入结构Group可能不好理解。这一章节希望可以讲明白这两点。
1.统一的缓存的value对象
//该类型实现了NodeValue接口
type String stringfunc (d String) Len() int {return len(d)
}
在上节讲解中, 我们存入的每一个元素(键值对)都要计算大小。为了能计算大小那存入缓存的 value 对象必须实现NodeValue接口的Len()方法。上一节的测试用例中存储的value对象是String也即是string。
那么问题来了, 我们存入的 value 可能是 string, int, 也可能自定义的结构体User等等。如果为每一种类型都实现一个 Len() 方法那确实是繁琐。因此我们希望将存入的每个 value 都转化为统一的类型, 比如:字节数组 []byte。
我们可以抽象了一个只读数据结构 ByteView 用来表示缓存值。
ByteView 只有一个数据成员b []byteb 将会存储真实的缓存值。
b 是只读的使用 ByteSlice() 方法返回一个拷贝防止缓存值被外部程序修改。
//缓存值的抽象与封装
type ByteView struct {b []byte
}func (v ByteView) Len() int {return len(v.b)
}func (v ByteView) ByteSlice() []byte {return cloneByte(v.b)
}func cloneByte(b []byte) []byte {c : make([]byte, len(b))copy(c, b)return c
}func (v ByteView) String() string {return string(v.b)
}
2.实现缓存并发读写
上一节实现的LRU算法是不支持并发读写的。Go中map不是线程安全的。要实现并发读写map,需要加锁可以使用sync.Mutex。
sync.Mutex 是一个互斥锁可以由不同的协程加锁和解锁。
先回顾下上一节定义的缓存的整体数据结构
type Cache struct {maxBytes int64 //允许的能使用的最大内存nbytes int64 //已使用的内存ll *list.List //双向链表cache map[string]*list.ElementOnEvicted func(key string, value NodeValue)
}
要是想的简单点我们可以在该结构体Cache内部加上sync.Mutex并修改其方法的部分原有逻辑来实现并发读写。但这样就破坏了对扩展开放对修改关闭的面向对象原则。这是不好的。 定义加锁的缓存对象
我们可以在Cache结构体基础上再封装一个可以支持并发读写的对象。
type cache struct {mutex sync.Mutexlru *lru.CachecacheBytes int64
}
显然该新对象中是需要有个互斥锁变量。而每个缓存对象都有能使用的最大内存量上限使用cacheBytes 字段来存储这个值。
该cache对象也基于互斥锁和lru封装了 get 和 add 方法。
func (c *cache) add(key string, value ByteView) {c.mutex.Lock()defer c.mutex.Unlock()if c.lru nil {c.lru lru.New(c.cacheBytes, nil)}c.lru.Add(key, value)
}func (c *cache) get(key string) (value ByteView, ok bool) {c.mutex.Lock()defer c.mutex.Unlock()if c.lru nil {return}if v, ok : c.lru.Get(key); ok {return v.(ByteView), ok}return
}
3.提升缓存并发读写能力
互斥锁引发的性能问题
引入锁之后可能会引起性能问题思考如下场景
当有 A个线程访问库存的缓存数据时, 我们给 cache 对象加了锁 如果此时有 B个线程来访问商品缓存数据这 A B 个线程就需要共同竞争一把锁。
要是线程数量大的话对性能是有影响的那是因为所有的缓存都被一把锁把持住。那要是我们可以把缓存进行分组这样首先就可以不用所有的线程都去抢一把锁了。
将缓存数据进行分组
为了提高缓存系统的并发读写的性能(降低锁的竞争程度) 我们想想是否可以再细分锁的范围分段锁的设计。 可以理解成是先分段再锁将原本的所有缓存分成了若干段分别将这若干段放在了不同的组中每个组有各自的锁以此提高效率。
如此设计之后, 不同组的存缓数据就隔离了起来, 访问同一组数据的线程才会互相竞争。
这就引出了Group这个结构。
4.Group结构
定义一个分组结构从上图也可知道要去访问缓存就需去找到该组那如何辨别是这个组呢这里就是通过组的名字去辨别的每个组都有个名字。
// 紧接着我们定义一个 分组 类型
type Group struct {name string // 分组名称mainCache cache // 单个缓存对象
}这时有多个组后那如何通过组名字快速找到该组了还是要用map。那肯定又涉及到多个线程并发读写 groups 。这里是找到对应组名字的组而加锁的。我们可以考虑用 读写锁 来解决这个问题。
这里使用读写锁应该比使用互斥锁可以提高并发度。
来看看创建组和通过名字获取组的函数
var (rwMu sync.RWMutexgroups make(map[string]*Group)
)func NewGroup(name string, cacheBytes int64) *Group {rwMu.Lock()defer rwMu.Unlock()g : Group{name: name,mainCache: cache{cacheBytes: cacheBytes},}groups[name] greturn g
}// 获取 Group 对象的方法
func GetGroup(name string) *Group {rwMu.RLock()defer rwMu.RUnlock()g : groups[name]return g
}
缓存查询回调方法
我们要考虑一种情况如果缓存不存在应从数据源文件数据库等获取数据并添加到缓存中。
该Cache 是否应该支持多种数据源的配置呢不应该一是数据源的种类太多没办法都实现二是扩展性不好。如何从源头获取数据应该是用户决定的事情我们就把这件事交给用户好了。因此我们设计了一个回调函数(callback)在缓存不存在时就可以调用该函数得到源数据。
这个回调方法我们可以直接定义在上面的 Get 方法的入参中,也可以放在 Group 对象中为了方便我们放在Group内。
type Group struct {name string // 组名mainCache cache // 单个缓存对象// 新增回调函数getter Getter}type Getter interface {Get(key string) ([]byte, error)
}type GetterFunc func(key string) ([]byte, error)func (f GetterFunc) Get(key string) ([]byte, error) {return f(key)
} 函数类型实现某一个接口称之为接口型函数那么该函数也是接口。
其好处当一个函数的参数类型是接口那使用者在调用时既能够传入函数作为参数也能够传入实现了该接口的结构体作为参数。
接口型函数不太理解的话可以看Go接口型函数。
接口型函数在这章节的最后测试中也会进行讲解的测试中有例子。 Group 的 Get 方法
首先从本地缓存中查找若是有则直接返回该缓存数据即可。
若是缓存不存在(即是没击中)则调用 load 方法调用用户回调函数 g.getter.Get() 获取源数据并且将源数据添加到缓存 mainCache 中。
func (g *Group) Get(key string) (ByteView, error) {if v, ok : g.mainCache.get(key); ok {return v, nil}return g.load(key)
}func (g *Group) load(key string) (ByteView, error) {bytes, err : g.getter.Get(key)if err ! nil {return ByteView{}, err}value : ByteView{b: cloneByte(bytes)}g.mainCache.add(key, value) //将源数据添加到缓存mainCachereturn value, nil
}
至此这一章节的单机并发缓存就已经完成了。
5.测试
// 缓存中没有的话就从该db中查找
var db map[string]string{tom: 100,jack: 200,sam: 444,
}// 统计某个键调用回调函数的次数
var loadCounts make(map[string]int, len(db))
创建 group 实例并测试 Get 方法。
主要测试了两种情况
1在缓存为空的情况下能够通过回调函数获取到源数据。2在缓存已经存在的情况下是否直接从缓存中获取为了实现这一点使用 loadCounts 统计某个键调用回调函数的次数如果次数大于1则表示调用了多次回调函数没有缓存。
func main() {//传函数入参 cache.GetterFunc(funcCbGet)是进行类型转换不是执行函数cache : cache.NewGroup(scores, 210, cache.GetterFunc(funcCbGet))//传结构体入参也可以// cbGet : search{}// cache : cache.NewGroup(scores, 210, cbGet)for k, v : range db {if view, err : cache.Get(k); err ! nil || view.String() ! v {fmt.Println(failed to get value of ,k)}if _, err : cache.Get(k); err ! nil || loadCounts[k] 1 {fmt.Printf(cache %s miss, k)}}if view, err : cache.Get(unknown); err nil {fmt.Printf(the value of unknow should be empty, but %s got, view)}else {fmt.Println(err)}
}// 函数的
func funcCbGet(key string) ([]byte, error) {fmt.Println(callback search key: , key)if v, ok : db[key]; ok {if _, ok : loadCounts[key]; !ok {loadCounts[key] 0}loadCounts[key] 1return []byte(v), nil}return nil, fmt.Errorf(%s not exit, key)
}// 结构体实现了Getter接口的Get方法
type search struct {
}func (s *search) Get(key string) ([]byte, error) {fmt.Println(struct callback search key: , key)if v, ok : db[key]; ok {if _, ok : loadCounts[key]; !ok {loadCounts[key] 0}loadCounts[key] 1return []byte(v), nil}return nil, fmt.Errorf(%s not exit, key)
}
讨论接口型函数
NewGroup中的最后一个参数类型是接口类型。
这里既可以传入函数也可以传入结构体变量。
而按照这个例子传入函数是很方便的。只写一个函数就行而做成结构体的话还需要新建一个结构体类型再实现Get方法这就是很麻烦的。
这里可能就有疑惑了大家通过这个例子明白这样做是既可以传入函数也可以传入结构体变量。但从这例子来看没必要这样做就只是传函数就行啦没必要把NewGroup的最后那个参数类型做成接口类型只弄成函数类型就行啦。
这是这个例子的要是在其他更加复杂的情况呢。比如如果对数据库的操作需要很多信息地址、用户名、密码还有很多中间状态需要保持比如超时、重连、加锁等等。这种情况下更适合将其封装为一个结构体再把该结构体传入更好。
既能够将普通的函数类型需类型转换作为参数也可以将结构体作为参数使用更为灵活可读性也更好这就是接口型函数的价值。
这样就不用等我们想要用结构体传参时候发现类型不符合传参失败就需要修改代码这时候就麻烦了。 完整代码https://github.com/liwook/Go-projects/tree/main/go-cache/2-single-node