本文目录一览:
什么是Redis?
REmote DIctionary Server(Redis) 是一个由Salvatore Sanfilippo写的key-value存储系统
Redis是一个开源的使用ANSIC语言编写、遵守BSD协议、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API
它通常被称为数据结构服务器,因为值(value)可以是 字符串(String), 哈希(Map), 列表(list), 集合(sets)和有序集合(sorted sets)等类型
Redis 简介
Redis是完全开源免费的,遵守BSD协议,是一个高性能的key-value数据库
Redis与其他key - value缓存产品有以下三个特点:
①Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
②Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。
③Redis支持数据的备份,即master-slave模式的数据备份。
Redis 的特点
高性能:Redis 将所有数据集存储在内存中,可以在入门级 Linux 机器中每秒写(SET)11 万次,读(GET)8.1 万次
持久化:当所有数据都存在于内存中时,可以根据自上次保存以来经过的时间和/或更新次数,使用灵活的策略将更改异步保存在磁盘上
数据结构:Redis 支持各种类型的数据结构,例如字符串、散列、集合、列表、带有范围查询的有序集、位图、超级日志和带有半径查询的地理空间索引
原子操作:处理不同数据类型的 Redis 操作是原子操作,因此可以安全地 SET 或 INCR 键,添加和删除集合中的元素等
支持的语言:Redis 支持许多语言,如C、C++、Erlang、Go、Haskell、Java、JavaScript(Node.js)、Lua、Objective-C、Perl、PHP、Python、R、Ruby、Rust、Scala、Smalltalk等
主/从复制:Redis 遵循非常简单快速的主/从复制。配置文件中只需要一行来设置它,而 Slave 在 Amazon EC2 实例上完成 10 MM
key 集的初始同步只需要 21 秒
分片:Redis 支持分片。与其他键值存储一样,跨多个 Redis 实例分发数据集非常容易
可移植:Redis 是用 C 编写的,适用于大多数 POSIX 系统,如 Linux、BSD、Mac OS X、Solaris 等
golang sync.pool对象复用 并发原理 缓存池
在go http每一次go serve(l)都会构建Request数据结构。在大量数据请求或高并发的场景中,频繁创建销毁对象,会导致GC压力。解决办法之一就是使用对象复用技术。在http协议层之下,使用对象复用技术创建Request数据结构。在http协议层之上,可以使用对象复用技术创建(w,*r,ctx)数据结构。这样即可以回快TCP层读包之后的解析速度,也可也加快请求处理的速度。
先上一个测试:
结论是这样的:
貌似使用池化,性能弱爆了???这似乎与net/http使用sync.pool池化Request来优化性能的选择相违背。这同时也说明了一个问题,好的东西,如果滥用反而造成了性能成倍的下降。在看过pool原理之后,结合实例,将给出正确的使用方法,并给出预期的效果。
sync.Pool是一个 协程安全 的 临时对象池 。数据结构如下:
local 成员的真实类型是一个 poolLocal 数组,localSize 是数组长度。这涉及到Pool实现,pool为每个P分配了一个对象,P数量设置为runtime.GOMAXPROCS(0)。在并发读写时,goroutine绑定的P有对象,先用自己的,没有去偷其它P的。go语言将数据分散在了各个真正运行的P中,降低了锁竞争,提高了并发能力。
不要习惯性地误认为New是一个关键字,这里的New是Pool的一个字段,也是一个闭包名称。其API:
如果不指定New字段,对象池为空时会返回nil,而不是一个新构建的对象。Get()到的对象是随机的。
原生sync.Pool的问题是,Pool中的对象会被GC清理掉,这使得sync.Pool只适合做简单地对象池,不适合作连接池。
pool创建时不能指定大小,没有数量限制。pool中对象会被GC清掉,只存在于两次GC之间。实现是pool的init方法注册了一个poolCleanup()函数,这个方法在GC之前执行,清空pool中的所有缓存对象。
为使多协程使用同一个POOL。最基本的想法就是每个协程,加锁去操作共享的POOL,这显然是低效的。而进一步改进,类似于ConcurrentHashMap(JDK7)的分Segment,提高其并发性可以一定程度性缓解。
注意到pool中的对象是无差异性的,加锁或者分段加锁都不是较好的做法。go的做法是为每一个绑定协程的P都分配一个子池。每个子池又分为私有池和共享列表。共享列表是分别存放在各个P之上的共享区域,而不是各个P共享的一块内存。协程拿自己P里的子池对象不需要加锁,拿共享列表中的就需要加锁了。
Get对象过程:
Put过程:
如何解决Get最坏情况遍历所有P才获取得对象呢:
方法1止前sync.pool并没有这样的设置。方法2由于goroutine被分配到哪个P由调度器调度不可控,无法确保其平衡。
由于不可控的GC导致生命周期过短,且池大小不可控,因而不适合作连接池。仅适用于增加对象重用机率,减少GC负担。2
执行结果:
单线程情况下,遍历其它无元素的P,长时间加锁性能低下。启用协程改善。
结果:
测试场景在goroutines远大于GOMAXPROCS情况下,与非池化性能差异巨大。
测试结果
可以看到同样使用*sync.pool,较大池大小的命中率较高,性能远高于空池。
结论:pool在一定的使用条件下提高并发性能,条件1是协程数远大于GOMAXPROCS,条件2是池中对象远大于GOMAXPROCS。归结成一个原因就是使对象在各个P中均匀分布。
池pool和缓存cache的区别。池的意思是,池内对象是可以互换的,不关心具体值,甚至不需要区分是新建的还是从池中拿出的。缓存指的是KV映射,缓存里的值互不相同,清除机制更为复杂。缓存清除算法如LRU、LIRS缓存算法。
池空间回收的几种方式。一些是GC前回收,一些是基于时钟或弱引用回收。最终确定在GC时回收Pool内对象,即不回避GC。用java的GC解释弱引用。GC的四种引用:强引用、弱引用、软引用、虚引用。虚引用即没有引用,弱引用GC但有空间则保留,软引用GC即清除。ThreadLocal的值为弱引用的例子。
regexp 包为了保证并发时使用同一个正则,而维护了一组状态机。
fmt包做字串拼接,从sync.pool拿[]byte对象。避免频繁构建再GC效率高很多。
一顿骚操作版本号比较性能提升300%
在一次性能分析中,发现线上服务 CompareVersion 占用了较长的CPU时间。如下图所示。
其中占用时间最长的为 strings.Split 函数,这个函数对Gopher来说应该是非常熟悉的。而 CompareVersion 就是基于 strings.Split 函数来实现版本比较的,下面看一下 CompareVersion 的实现。
CompareVersion 的逻辑清晰且简单,而根据火焰图知性能主要消耗在 strings.Split 函数上,所以老许的第一目标是尝试优化 strings.Split 函数。
每当此时老许首先想到的方法就是百度大法和谷歌大法,最后在某篇文章中发现 strings.FieldsFunc 函数,根据该文章描述, strings.FieldsFunc 函数分割字符串的速度远快于 strings.Split 函数。那么我们到底能不能使用 strings.FieldsFunc 函数替换 strings.Split 函数请看下面测试结果。
上述benchmark测试在老许的机器上某次运行结果如下:
根据输出知, strings.FieldsFunc 函数没有想象中那么快,甚至还比不过 strings.Split 函数。既然此路不通,老许只好再另寻他法。
按照最卷的公司来,假如我们每周一个版本,且全年无休则一个公司要发布1000个版本需 19年 (1000/(365 / 7))。基于这个内卷的数据,我们如果能够把这些版本都缓存起来,然后再比较大小,其执行速度绝对有一个质的提升。
要引入缓存的话,老许第一个想到的就是过期缓存。同时为了尽可能的轻量所以自己实现一个过期缓存无疑是一个不错的方案。
1、定义一个包含过期时间和数据的结构体
2、使用 sync.Map 作为并发安全的缓存
3、定义一个通过 . 分割可存储每部分数据的结构体
4、将app版本转为切片以方便缓存
5、使用带缓存的方式进行版本大小比较
CompareVersionWithCache1 函数比较步骤为:
最后进行性能验证,以下为 CompareVersionWithCache1 函数和 CompareVersion 函数的benchmark对比。
通过上述结果分析知,使用缓存后唯一的优化只是减少了微乎其微的内存分配。这个结果实在令老许充满了疑惑,在使用pprof分析后终于发现性能没有提升的原因。以下为benchmark期间 BenchmarkCompareVersionWithCache1 函数的火焰图。
因为考虑到app版本数量较小,所以使用了惰性淘汰的方式淘汰过期缓存,在每次读取数据时判断缓存是否过期。根据火焰图知性能损耗最大的就是判断缓存是否过期,每次判断缓存是否过期都需要调用 time.Now().Unix() 得到当前时间戳。也就是因为 time.Now() 的这个调用导致这次优化功亏一篑。
考虑到版本数量本身不多,且对于常用的版本可以尽可能永久缓存,因此引入LRU缓存做进一步性能优化尝试。
1、引入开源的LRU缓存,对应开源库为: github.com/hashicorp/golang-lru
2、在 CompareVersionWithCache1 函数的基础上将读写缓存替换为引入的LRU缓存
最后进行性能验证,以下为 CompareVersionWithCache2 函数和 CompareVersion 函数的benchmark对比。
哎,这个结果终于有点样子了,但优化效果并不明显,还有进一步提升的空间。
选择LRU缓存是有效果的,在这个基础上老许决定自己实现一个极简的LRU缓存。
1、定义一个缓存节点结构体
2、 定义一个操作LRU缓存的结构体
3、LRU缓存的Set方法
4、LRU缓存的Get方法
5、在 CompareVersionWithCache1 函数的基础上将读写缓存替换为自实现的LRU缓存
最后进行性能验证,以下为 CompareVersionWithCache3 函数和 CompareVersion 函数的benchmark对比:
引入自实现的LRU缓存后,性能足足提升了一倍,到这里老许几乎准备去公司装逼了,但是心里总有个声音在问我有没有无锁的方式读取缓存。
无锁的方式确实没有想到,只想到了两种减少锁竞争的方式。
加入随机数后的实现如下:
加入随机数后的 CompareVersionWithCache3 函数和 CompareVersion 函数的benchmark对比如下:
加入随机数后, CompareVersionWithCache3 函数性能再次提升 20% 左右。优化还没结束,当缓存数量远不足设置的缓存上限时不需要移动到链表头。
引入上述优化后,benchmark对比如下:
至此,最终版的版本比较实现在理想情况下(缓存空间较足)性能达到原先的 4 倍。
本来老许都准备去公司装逼了,万万没想到同事已经搞了一个更加合理且稳定的版本比较算法,让老许自愧不如。
该算法思路如下:
三种算法benchmark如下:
BenchmarkCompareVersionNoSplit 函数不需要引入缓存,也不会像 BenchmarkCompareVersionWithCache3 中的缓存数量接近上限后会有一定的性能损失,几乎是我目前发现的最为理想的版本比较方案。
老许也不说什么当局者迷,旁观者清这种酸葡萄一般的话,只得承认有的人就是老天爷赏饭吃。有一说一碰上这种人是我的幸运,我相信他只要有口饭吃,我就能在他屁股后面蹭口汤喝。关于文中最后提到的版本号比较算法完整实现请至下面的github仓库查看:
最后,衷心希望本文能够对各位读者有一定的帮助。
本文链接:https://my.lmcjl.com/post/19642.html
4 评论