cache教程 2.单机并发缓存

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 []byte,b 将会存储真实的缓存值。

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", 2<<10, cache.GetterFunc(funcCbGet))//传结构体入参,也可以// cbGet := &search{}// cache := cache.NewGroup("scores", 2<<10, 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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/216407.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

linux中堡垒机

堡垒机 堡垒机概念目的 安装Jumpserver使用资产管理应用管理附加设置问题 堡垒机 概念 堡垒机&#xff0c;就是在一个特定的网络环境下&#xff0c;为了保障网络和数据不受来自外部和内部用户的入侵和破坏&#xff0c;采用各种技术手段监控和记录运维人员对网络内的服务器、网…

12.11_黑马数据结构与算法笔记Java

目录 070 栈 链表实现 概念理清&#xff1a;什么时候是指针的指向&#xff0c;什么时候是元素本身&#xff1f; 071 栈 数组实现 072 栈 e01 有效的括号 072 栈 e02 后缀表达式求值 072 栈 e03 中缀表达式转后缀1 072 栈 e03 中缀表达式转后缀2 072 栈 e03 中缀表达式转…

来看!天津靠谱Java培训机构哪家更好一点?

Java培训对某些计算机爱好者和IT从业者来说&#xff0c;是一道必经之路。包括Java基础、JavaWeb、JavaEE、大数据开发、人工智能等多方面的技术领域。参加Java培训可以帮助我们掌握计算机编程语言的基础知识&#xff0c;掌握Java语言编程技巧&#xff0c;快速提高编程水平。 报…

JVM虚拟机系统性学习-运行时数据区(方法区、程序计数器、直接内存)

方法区 方法区本质上是 Java 编译后代码的存储区域&#xff0c;存储了每一个类的结构信息&#xff0c;如&#xff1a;运行时常量池、成员变量、方法、构造方法和普通方法的字节码指令等内容 方法区主要存储的数据如下&#xff1a; Class 类型信息&#xff0c;如该 Class 为 …

人工智能在安全领域的应用

ChatGPT 等 AI 应用在网络安全领域的应用效果明显&#xff0c;其自动编程能力、分析能力及自身集成的知识库能够帮助网络安全从业者提升工作效率&#xff0c;改进组织的网络安全计划。 &#xff08;一&#xff09;代码生成与检测能力 可用于开发漏洞挖掘工具。如目前可以利用…

欧拉函数与欧拉定理

文章目录 AcWing 873. 欧拉函数题目链接欧拉函数欧拉函数的证明思路CODE时间复杂度分析 AcWing 874. 筛法求欧拉函数题目链接问题分析与时间复杂度CODE思路 欧拉定理 AcWing 873. 欧拉函数 题目链接 https://www.acwing.com/activity/content/problem/content/942/ 欧拉函数 …

Keil新建STM32软件工程 - (详细步骤图文)

文章目录 1. 前言2. 下载芯片对应的Keil开发包3. 下载芯片对应的标准外设库 - STM32F10x_StdPeriph_Lib_Vx.x.x4. 新建工程文件夹 - Demo34.1 移植标准外设库4.2 启动文件介绍及如何选择 5. 新建软件工程 - Demo5.1 打开Keil → Project → New uVision Project5.2 选择芯片型号…

从头到尾的数据之旅

目录 引言 链表介绍 单向链表的接口实现 结构 创建节点 头插 尾插 头删 尾删 打印 节点查找 节点前插入 节点删除 内存释放 总结 引言 在前面的学习中&#xff0c;我们深入了解了顺序表&#xff0c;通过其增删查改的操作&#xff0c;我们发现了顺序表在某些情况…

如何批量给文件名加相同字段?

如何批量给文件名加相同字段&#xff1f;文件名批量修改是一项蕴藏了非常多知识的电脑操作技能&#xff0c;批量修改文件名称不仅仅是修改名称这么简单&#xff0c;有时候我们还可能需要批量王文件名称中添加一些相同的文字字段&#xff0c;批量操作注重的是高效率&#xff0c;…

《Vue.js设计与实现》—Vue3响应系统的原理

一、响应式数据与副作用函数 1. 副作用函数 1-1 指令材料 在JavaScript中&#xff0c;副作用函数是指在执行过程中对外部环境产生可观察的变化或影响的函数。这种函数通常会修改全局变量、修改传入的参数、执行I/O操作&#xff08;如读写文件或发送网络请求&#xff09;、修…

论文阅读《Learning Adaptive Dense Event Stereo from the Image Domain》

论文地址&#xff1a;https://openaccess.thecvf.com/content/CVPR2023/html/Cho_Learning_Adaptive_Dense_Event_Stereo_From_the_Image_Domain_CVPR_2023_paper.html 概述 事件相机在低光照条件下可以稳定工作&#xff0c;然而&#xff0c;基于事件相机的立体方法在域迁移时性…

【教程】开始使用ipaguard进行代码加固混淆

开始使用ipaguard 前言 iOS加固保护是直接针对ios ipa二进制文件的保护技术&#xff0c;可以对iOS APP中的可执行文件进行深度混淆、加密。使用任何工具都无法逆向、破解还原源文件。对APP进行完整性保护&#xff0c;防止应用程序中的代码及资源文件被恶意篡改。Ipa Guard通过…

springMVC 学习总结(三) 拦截器及统一异常处理

一.拦截器 1.拦截器与过滤器的区别 拦截器 Interceptor 和 过滤器 Filter类似&#xff0c;主要用于拦截用户请求并作出一定处理操作&#xff0c; 但两则也有不同之处&#xff0c;如过滤器只在Servlet前后起作用&#xff0c;是Servlet规范定义的&#xff0c;是Servlt容器才支…

NGINX高性能服务器与关键概念解析

目录 1 NGINX简介2 NGINX的特性3 正向代理4 反向代理5 负载均衡6 动静分离7 高可用8 结语 1 NGINX简介 NGINX&#xff08;“engine x”&#xff09;在网络服务器和代理服务器领域备受推崇。作为一款高性能的 HTTP 和反向代理服务器&#xff0c;它以轻量级、高并发处理能力以及…

2-1基础算法-枚举/模拟

文章目录 1.枚举2.模拟 1.枚举 [例1] 特别数的和 评测系统 #include <iostream> using namespace std; bool pa(int x) {while (x) {if (x % 10 2 || x % 10 1 || x % 10 0 || x % 10 9) {return true;}else {x x / 10;}}return false; } int main() {int sum0;i…

【pytest】单元测试文件的写法

前言 可怜的宾馆&#xff0c;可怜得像被12月的冷雨淋湿的一条三只腿的黑狗。——《舞舞舞》 \;\\\;\\\; 目录 前言test_1或s_test格式非测试文件pytest.fixture()装饰器pytestselenium test_1或s_test格式 要么 test_前缀 在前&#xff0c;要么 _test后缀 在后&#xff01; …

低压无功补偿在分布式光伏现场中的应用

摘要&#xff1a;分布式光伏电站由于建设时间短、技术成熟、收益明显而发展迅速&#xff0c;但光伏并网引起用户功率因数异常的问题也逐渐凸显。针对分布式光伏电站接入配电网后功率因数降低的问题&#xff0c;本文分析了低压无功补偿装置补偿失效的原因&#xff0c;并提出了一…

代码随想录算法训练营第46天| 139.单词拆分 多重背包

JAVA代码编写 139.单词拆分 给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。 **注意&#xff1a;**不要求字典中出现的单词全部都使用&#xff0c;并且字典中的单词可以重复使用。 示例 1&#xff1a; 输入: s &…

Java - JVM内存模型及GC(垃圾回收)机制

JVM内存模型 JVM堆内存划分&#xff08;JDK1.8以前&#xff09; JVM堆内存划分&#xff08;JDK1.8之后&#xff09; 主要变化在于&#xff1a; java8没有了永久代&#xff08;虚拟内存&#xff09;&#xff0c;替换为了元空间&#xff08;本地内存&#xff09;。常量池&#…

数据库中常用的锁

目录 1、数据库中常用的锁类型 2、常见的数据库 3、以MySQL为例 3.1 MySQL的事务 3.2 MySQL事务的四大特性 1. 原子性&#xff08;Atomicity&#xff09; 2. 一致性&#xff08;Consistency&#xff09; 3. 隔离性&#xff08;Isolation&#xff09; ⭐mysql中的事务隔…