本帖最后由 结冰的离季 于 2023-3-20 01:45 编辑
本文也发布于博客: https://www.iseason.top/data-cache/
什么是缓存
缓存(Cache),原始意义是指访问速度比一般随机存取存储器(RAM)快的一种RAM,通常它不像系统主存那样使用DRAM技术,而使用昂贵但较快速的SRAM技术。如今缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和硬盘之间也有Cache(磁盘缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。
举一些通俗的例子:
本文章的重点将放在内存缓存上
为什么需要缓存
在我们的程序开发中经常会存在大量数据读写或者大量数据运算导致线程阻塞或造成性能下降的问题。
就MC来说,主线程堵塞是一个致命的问题,而异步进行IO操作会造成延迟,在某些需要高一致性的场景中不合适,且大量的IO也会造成网络、磁盘性能的降低。
相比于网络、硬盘等IO操作,内存的读写的速度就如闪电般快速, 足以胜任高频、大量的数据吞吐,故而将部分或全部的数据储存到内存中进行读写可以极大地提高代码性能。
这里提一下最近遇到的大量运算阻塞线程的例子 EcoCrates, 由于需要根据玩家权限和奖品限量计算权重,每计算一次权重就得遍历整个奖池。如果有1000个奖品它将会有 1000^2 次计算,在动画中每tick又计算一次,导致奖品一多就卡住整个线程,这完全是可以使用缓存来避免的。
如果你发现你的程序卡顿严重,那么试试缓存吧!
常用的内存缓存技术有哪些
以进程为界限,常用的内存缓存大致分为2种(注意以下概念仅是我自己总结的,可能与实际有冲突)
进程内缓存: 进程内的缓存适用于需要频繁访问的数据,如配置文件、用户信息、静态页面等。Java最常用的进程缓存有 HashMap 、ConcurrentHashMap、 Ehcache、 Caffeine等。
进程间缓存: 进程之间的缓存主要通过共享内存或进程接口来访问,它可以是同一个机器上的,也可以机器是与机器之间的缓存,通常情况下进程间的缓存以不同机器为主,常使用网络接口访问,如: Redis、 Memcached、 Hazelcast 、Couchbase 、Apache Ignite,常用于分布式系统的缓存服务中。
以上缓存概念还可以细分下去,比如进程内缓存可以细分为线程缓存、CPU指令集缓存、栈和堆、以及其他各种容器。
不同的进程缓存是可以相互配合的,你可以构建多级缓存来适应不同的情况。
以下是Web服务中的一种常用的缓存架构,使用了Nginx用到了Redis分布式缓存,Nginx、Tomcat、等服务使用自己的进程缓存
内存缓存有哪些不足
万物皆有利弊, 内存缓存最大的问题在于内存, 内存不比磁盘,容量十分有限,无止境地使用内存缓存会占用大量空间,影响其他模块运行,所以无论是什么内存缓存都需要适度。
其次是数据一致性, 使用了缓存意味着数据的状态也被定格,对原始数据的修改需要人为地同步到缓存中,否则很容易出现诸如商品超卖等问题。
再者是代码复杂度变高, 缓存好用,但也难用. 代码量增大是一方面,不合理的缓存还会造成内存泄漏、性能变差等负面影响。
缓存的淘汰策略
内存是限量的,所以需要控制,手动控制缓存将会非常麻烦,所以衍生了很多缓存的控制算法,也叫缓存淘汰策略
缓存淘汰策略通常由缓存中间件/SDK实现,这里仅作介绍(由ChatGPT解答)
内存缓存的三大问题
缓存穿透*
假如你实现了一个右键方块运行命令的插件,你一定会遇到缓存穿透的问题。缓存穿透,顾名思义就是请求到了缓存中不存在的数据,那么缓存将毫无意义。
方块->命令的数据结构通常我们会以方块的坐标为id构建一个HashMap映射,这也是最简单的缓存实现,
但我们不能把所有的数据都储存到缓存中,因为内存是有限的。如果你有大量的数据存储需要,那么数据库是一个很好的媒介,你可以得到以下流程
这样会有什么问题,你可以思考下。
我们知道世界坐标无穷无尽,右键到的方块恰好是绑定了命令的概率有多少?可以说是万中无一,那么以上模型中查询数据库的情况成了大头,它绕过了缓存直击数据库,也就是缓存穿透。
从定义上来说明就是: 缓存穿透是指当缓存中不存在请求的数据时,该请求会穿过缓存层,直接请求数据库或后端服务,这可能会导致数据库或后端服务的负载增加,而且由于请求的数据在缓存中不存在,这些请求也无法被缓存,进一步加重了数据库或后端服务的负载,从而影响系统性能。
可以预见高频地请求数据库会造成什么问题。
缓存穿透 - 解决方案
缓存穿透的关键在于不存在的key, 那么解决方案就得围绕这个key进行, 以下是部分解决方案
Key校验
有时候key是用户输入的,非程序控制。那么用户输入的key很可能不符合规则,比如我们以邮箱为key,那么只需要判断用户输入的key是否为邮箱,就可以过滤大量的非法输入。
缓存空值
将请求不存在的结果也缓存起来,但值为空,这样对于后续的相同请求,就可以从缓存中获取结果,而不会再次穿透到数据库或后端服务。需要注意的是,需要设置一个过期时间,避免缓存空对象一直存在于缓存中。
数据预热
在系统启动时或者某个时间点,提前将一些热门数据加载到缓存中,这样能够避免在高并发请求时出现缓存穿透。
限流
通过限制请求的频率或者数量,避免过多的请求打到后端服务或数据库上,从而避免缓存穿透。
异步
当发现缓存中不存在请求的数据时,不立即去请求后端服务或数据库,而是使用异步加载的方式,将请求加入到队列中(线程池),等待后续的查询结果。这样能够避免对后端服务或数据库的瞬时高并发请求,从而减轻系统的压力。
过滤器*
请求不存在的Key? 如果我能够在请求前就知道Key是否存在,那么就不用去查询数据库了,就是这么神奇,通过过滤器算法,提取数据特征,可以快速地判断数据是否存在集合中。
以下是几种常用的过滤器算法
以上过滤器中以布隆过滤器使用频率最高,但个人更推荐布谷鸟过滤器,具体的选型应该以实际情况判断,如果你的数据不需要删除,那么布隆过滤器是很好的选择,如果需要删除数据,那么布谷鸟过滤器是一个选择
关于过滤器的SDK可以看这个网址 https://bdupras.github.io/filter-tutorial/ https://github.com/bdupras/guava-probably
缓存击穿
当一个缓存中的数据失效或者不存在时,大量请求同时涌入数据库,导致数据库瞬间压力过大而崩溃的情况。常出现在redis等高并发分布式系统中。由于插件应用场景较少,我也没有过多了解,这里仅作介绍。
以下是ChatGPT给出的常用解决方案
缓存雪崩
由于缓存中大量的数据同时失效或者清空,导致所有的请求都涌入到数据库,最终导致数据库瞬间过载崩溃的情况。同样用的少,由ChatGPT解答
介绍到这想必你对于缓存的概念及作用有了一定了解,不妨思考下自己的项目有哪些可以使用缓存的地方
解决方案——多级缓存与过滤器
这个部分我将解析我的绑定插件关于缓存的实现 项目地址: https://github.com/SakuraTown/SakuraBind
项目使用的缓存工具为 Ehcache 。相比于 Caffeine 等其他工具 它具有独特的缓存持久化功能,类似于Redis
开发语言为 Kotlin
物品数据二级缓存
问题描述
绑定具有选择器,可以在玩家右键等情况识别特定类型的物品,应用不同的绑定设置,yml数据结构如下
复制代码
当玩家右键物品时将会遍历 matchers 下的所有选择器根据 match 配置的规则匹配物品,如上是选择BOW类型的物品。如果未匹配则使用全局的设置。
比如有以下伪代码
复制代码
很显然,如果每次右键都去遍历所有选择器将会造成性能问题,特别是选择器多的情况
解决思路
既然考虑缓存,那么不难分析出以下可以缓存的数据
从缓存访问顺序来讲,我们说的n级缓存其实就是缓存的访问顺序,所以一级缓存应该对应 持久化的物品匹配结果,二级缓存为 物品的匹配结果
代码实现
实际实现
复制代码
方块数据过滤器
问题描述
如上面提到的,由于bukkit没有方块的持久化数据接口,所以需要自己外挂数据。我使用的方案是通过方块坐标的字符串来绑定数据
比如 Key = world,20,64,-158 表示world世界里的 20,64,-158坐标 以此为Key
获取方块数据的情况多且频率高,比如 玩家破坏、点击方块、BlockPhysicsEvent、活塞推动方块等一切方块有关的事件我都用了
缓存穿透是最大的问题,有特殊数据的方块是远远小于其他方块的
解决思路
方块数据量是未知的,所以我按照大量来预估,应该具有的缓存如下
缓存穿透可以使用过滤器,由于方块数据会发生变化(增删查改),所以我选择能删除的布谷鸟过滤器
所以还多了个过滤器的数据缓存
代码实现
复制代码
结语
本文简述了数据的缓存与在改善性能方面的应用,为个人的经验总结,存在不足与缺失,如有错误请不吝指出。
本人是插件开发者,见过很多存在性能隐患的插件,如果是练手或自用无妨,但是有很多插件都发布了,这潜在的问题就可能对别人造成影响,甚至是财产损失。我希望所有发布插件/mod的作者都能自省,主动减少安全/性能隐患,这不仅是对他人负责,也是自己能力的提升。希望本文能有助于新手开发者。
本文也发布于博客: https://www.iseason.top/data-cache/
什么是缓存
缓存(Cache),原始意义是指访问速度比一般随机存取存储器(RAM)快的一种RAM,通常它不像系统主存那样使用DRAM技术,而使用昂贵但较快速的SRAM技术。如今缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和硬盘之间也有Cache(磁盘缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。
举一些通俗的例子:
- 将网络文件储存在本地磁盘中,以节省下载时间
- 将磁盘数据储存在内存中, 以提高访问速度
- 将复杂计算结果储存在内存中,以减少计算次数
...
本文章的重点将放在内存缓存上
为什么需要缓存
在我们的程序开发中经常会存在大量数据读写或者大量数据运算导致线程阻塞或造成性能下降的问题。
就MC来说,主线程堵塞是一个致命的问题,而异步进行IO操作会造成延迟,在某些需要高一致性的场景中不合适,且大量的IO也会造成网络、磁盘性能的降低。
相比于网络、硬盘等IO操作,内存的读写的速度就如闪电般快速, 足以胜任高频、大量的数据吞吐,故而将部分或全部的数据储存到内存中进行读写可以极大地提高代码性能。
这里提一下最近遇到的大量运算阻塞线程的例子 EcoCrates, 由于需要根据玩家权限和奖品限量计算权重,每计算一次权重就得遍历整个奖池。如果有1000个奖品它将会有 1000^2 次计算,在动画中每tick又计算一次,导致奖品一多就卡住整个线程,这完全是可以使用缓存来避免的。
如果你发现你的程序卡顿严重,那么试试缓存吧!
常用的内存缓存技术有哪些
以进程为界限,常用的内存缓存大致分为2种(注意以下概念仅是我自己总结的,可能与实际有冲突)
进程内缓存: 进程内的缓存适用于需要频繁访问的数据,如配置文件、用户信息、静态页面等。Java最常用的进程缓存有 HashMap 、ConcurrentHashMap、 Ehcache、 Caffeine等。
进程间缓存: 进程之间的缓存主要通过共享内存或进程接口来访问,它可以是同一个机器上的,也可以机器是与机器之间的缓存,通常情况下进程间的缓存以不同机器为主,常使用网络接口访问,如: Redis、 Memcached、 Hazelcast 、Couchbase 、Apache Ignite,常用于分布式系统的缓存服务中。
以上缓存概念还可以细分下去,比如进程内缓存可以细分为线程缓存、CPU指令集缓存、栈和堆、以及其他各种容器。
不同的进程缓存是可以相互配合的,你可以构建多级缓存来适应不同的情况。
以下是Web服务中的一种常用的缓存架构,使用了Nginx用到了Redis分布式缓存,Nginx、Tomcat、等服务使用自己的进程缓存

内存缓存有哪些不足
万物皆有利弊, 内存缓存最大的问题在于内存, 内存不比磁盘,容量十分有限,无止境地使用内存缓存会占用大量空间,影响其他模块运行,所以无论是什么内存缓存都需要适度。
其次是数据一致性, 使用了缓存意味着数据的状态也被定格,对原始数据的修改需要人为地同步到缓存中,否则很容易出现诸如商品超卖等问题。
再者是代码复杂度变高, 缓存好用,但也难用. 代码量增大是一方面,不合理的缓存还会造成内存泄漏、性能变差等负面影响。
缓存的淘汰策略
内存是限量的,所以需要控制,手动控制缓存将会非常麻烦,所以衍生了很多缓存的控制算法,也叫缓存淘汰策略
缓存淘汰策略通常由缓存中间件/SDK实现,这里仅作介绍(由ChatGPT解答)
- 最近最少使用(Least Recently Used, LRU):淘汰掉最近最少使用的缓存数据,即长时间没有被访问的数据,保留最近被频繁访问的缓存数据。(推荐)
- 先进先出(First In First Out,FIFO):淘汰掉最早进入缓存的数据,即先缓存的数据先被淘汰。
- 最少使用(Least Frequently Used,LFU):淘汰掉最少被使用的缓存数据,即访问次数最少的数据,保留访问次数多的数据。(推荐)
- 随机(Random):随机淘汰掉缓存中的一些数据。
- 定期清理(Scheduled):定期清理缓存中的数据,可以通过时间间隔、缓存大小等方式来触发清理操作。
- ...
内存缓存的三大问题
缓存穿透*
假如你实现了一个右键方块运行命令的插件,你一定会遇到缓存穿透的问题。缓存穿透,顾名思义就是请求到了缓存中不存在的数据,那么缓存将毫无意义。
方块->命令的数据结构通常我们会以方块的坐标为id构建一个HashMap映射,这也是最简单的缓存实现,
但我们不能把所有的数据都储存到缓存中,因为内存是有限的。如果你有大量的数据存储需要,那么数据库是一个很好的媒介,你可以得到以下流程

这样会有什么问题,你可以思考下。
我们知道世界坐标无穷无尽,右键到的方块恰好是绑定了命令的概率有多少?可以说是万中无一,那么以上模型中查询数据库的情况成了大头,它绕过了缓存直击数据库,也就是缓存穿透。
从定义上来说明就是: 缓存穿透是指当缓存中不存在请求的数据时,该请求会穿过缓存层,直接请求数据库或后端服务,这可能会导致数据库或后端服务的负载增加,而且由于请求的数据在缓存中不存在,这些请求也无法被缓存,进一步加重了数据库或后端服务的负载,从而影响系统性能。
可以预见高频地请求数据库会造成什么问题。
缓存穿透 - 解决方案
缓存穿透的关键在于不存在的key, 那么解决方案就得围绕这个key进行, 以下是部分解决方案
Key校验
有时候key是用户输入的,非程序控制。那么用户输入的key很可能不符合规则,比如我们以邮箱为key,那么只需要判断用户输入的key是否为邮箱,就可以过滤大量的非法输入。
缓存空值
将请求不存在的结果也缓存起来,但值为空,这样对于后续的相同请求,就可以从缓存中获取结果,而不会再次穿透到数据库或后端服务。需要注意的是,需要设置一个过期时间,避免缓存空对象一直存在于缓存中。
数据预热
在系统启动时或者某个时间点,提前将一些热门数据加载到缓存中,这样能够避免在高并发请求时出现缓存穿透。
限流
通过限制请求的频率或者数量,避免过多的请求打到后端服务或数据库上,从而避免缓存穿透。
异步
当发现缓存中不存在请求的数据时,不立即去请求后端服务或数据库,而是使用异步加载的方式,将请求加入到队列中(线程池),等待后续的查询结果。这样能够避免对后端服务或数据库的瞬时高并发请求,从而减轻系统的压力。
过滤器*
请求不存在的Key? 如果我能够在请求前就知道Key是否存在,那么就不用去查询数据库了,就是这么神奇,通过过滤器算法,提取数据特征,可以快速地判断数据是否存在集合中。
以下是几种常用的过滤器算法
- 布隆过滤器(Bloom Filter)
一种基于哈希函数的数据结构,用于快速检测一个元素是否在一个集合中。它由一个二进制向量和多个哈希函数组成。当一个元素加入到集合中时,通过多个哈希函数对该元素进行哈希计算,并将结果对向量中的对应位置设置为1。当需要判断一个元素是否在集合中时,同样对该元素进行哈希计算,如果所有的哈希函数对应位置上的值都为1,则判断该元素在集合中,如果存在一个位置上的值为0,则可以确定该元素不在集合中。
布隆过滤器具有高效的查询速度和低的存储空间要求,但是它存在一定的误判率,即存在元素不在集合中,但查询结果却认为其在集合中的情况。 - 布谷鸟过滤器(Cuckoo Filter)
一种改进的哈希过滤器,它使用哈希表来存储元素,并使用二次哈希函数来计算元素在哈希表中的位置。布谷鸟过滤器通过使用二次哈希函数来计算元素在哈希表中的位置,可以避免哈希冲突的问题,并且相比于布隆过滤器具有更高的存储效率。
布谷鸟过滤器使用两个哈希函数来计算元素的哈希值,并根据这两个哈希值计算出元素在哈希表中的两个位置。如果这两个位置上的其中一个为空,那么元素就可以被插入到这个位置上。如果两个位置都已经被占用,那么就需要使用一个已经占用的位置替换掉其中一个位置,并将被替换掉的元素插入到其哈希值所对应的位置。如果插入失败,则说明该元素已经存在于哈希表中。通过这种方式,布谷鸟过滤器可以保证插入和查询操作的时间复杂度都为常数级别。 - 计数布隆过滤器(Counting Bloom Filter)
一种布隆过滤器的变种,它在布隆过滤器的基础上增加了计数器,用于统计每个元素的出现次数。 - 墓碑布隆过滤器(Tombstone Bloom Filter)
一种解决布隆过滤器误判率较高的问题的变种,它可以标记已经被删除的元素,避免误判已删除的元素为存在。 - 商过滤器(Quotient Filter)
一种基于整除的哈希过滤器,它使用哈希函数将元素映射到哈希表的位置,并使用链表来解决哈希冲突。相比于布隆过滤器和布谷鸟过滤器,商过滤器具有更高的存储效率,并且具有更低的误判率。 - 熵过滤器(Entropy Filter)
是一种相对较新的过滤器,它与布隆过滤器和布谷鸟过滤器一样,也用于判断一个元素是否存在于一个集合中。熵过滤器使用基于信息熵的方法来估计元素集合的熵值,并将熵值作为过滤器的阈值。
以上过滤器中以布隆过滤器使用频率最高,但个人更推荐布谷鸟过滤器,具体的选型应该以实际情况判断,如果你的数据不需要删除,那么布隆过滤器是很好的选择,如果需要删除数据,那么布谷鸟过滤器是一个选择
关于过滤器的SDK可以看这个网址 https://bdupras.github.io/filter-tutorial/ https://github.com/bdupras/guava-probably
缓存击穿
当一个缓存中的数据失效或者不存在时,大量请求同时涌入数据库,导致数据库瞬间压力过大而崩溃的情况。常出现在redis等高并发分布式系统中。由于插件应用场景较少,我也没有过多了解,这里仅作介绍。
以下是ChatGPT给出的常用解决方案
- 加锁机制:在缓存失效的时候,对于热点数据的访问进行加锁,只允许一个请求到后端数据库中查询数据并将数据更新到缓存中,其他请求等待该请求的结果,从而避免了大量请求同时访问数据库的情况。
- 布隆过滤器:使用布隆过滤器来缓存一些非常热门的数据的key,如果一个请求的key不在布隆过滤器中,那么就可以直接返回没有该数据,从而避免了访问后端数据库。
- 设置短暂过期时间:为缓存设置短暂过期时间,避免缓存数据在同一时间全部失效,可以将过期时间设置为随机时间,以避免同一时间大量数据失效。
- 前置淘汰:在缓存中增加一个前置淘汰策略,定期扫描缓存中的数据,如果发现数据已经失效,就将该数据从缓存中删除,从而避免了请求到后端数据库的情况。
- 永不过期:对于一些非常重要的数据,可以设置缓存永不过期,从而保证数据的可用性,不过需要注意的是,缓存数据过多会影响系统性能。
缓存雪崩
由于缓存中大量的数据同时失效或者清空,导致所有的请求都涌入到数据库,最终导致数据库瞬间过载崩溃的情况。同样用的少,由ChatGPT解答
- 缓存数据分布:将缓存数据分布到多个缓存节点上,避免所有缓存节点的数据同时失效,从而避免了请求全部落到后端数据库的情况。
- 热点数据预热:在系统运行过程中,对于热点数据提前进行缓存预热,即在缓存数据过期之前,重新将热点数据缓存到缓存中,从而避免了缓存数据同时失效的情况。
- 备份缓存:在缓存层中备份一份数据,当缓存失效时,先从备份缓存中读取数据,避免请求全部落到后端数据库。
- 延迟请求:在缓存失效之后,可以先将请求进行一定的延迟,等待缓存重新加载热点数据,避免请求全部落到后端数据库。
- 限流策略:针对缓存失效后的高并发请求,可以采用限流策略,控制请求的并发量,避免大量请求同时访问后端数据库。
介绍到这想必你对于缓存的概念及作用有了一定了解,不妨思考下自己的项目有哪些可以使用缓存的地方
解决方案——多级缓存与过滤器
这个部分我将解析我的绑定插件关于缓存的实现 项目地址: https://github.com/SakuraTown/SakuraBind
项目使用的缓存工具为 Ehcache 。相比于 Caffeine 等其他工具 它具有独特的缓存持久化功能,类似于Redis
开发语言为 Kotlin
物品数据二级缓存
问题描述
绑定具有选择器,可以在玩家右键等情况识别特定类型的物品,应用不同的绑定设置,yml数据结构如下
- matchers: # 选择器节点
- example: # 选择器ID
- match: #选择类型
- material: BOW
- settings: #特定的设置
- item-deny:
- interact: false
当玩家右键物品时将会遍历 matchers 下的所有选择器根据 match 配置的规则匹配物品,如上是选择BOW类型的物品。如果未匹配则使用全局的设置。
比如有以下伪代码
- fun matchItemSetting(item:ItemStack): String =
- matchers.find{
- it.match(item) //检查是否匹配
- } ?: "GlocalSetting“ //未匹配返回全局设置
很显然,如果每次右键都去遍历所有选择器将会造成性能问题,特别是选择器多的情况
解决思路
既然考虑缓存,那么不难分析出以下可以缓存的数据
- 物品的匹配结果,也就是一次 matchItemSetting方法之后的数据,物品的特征不会改变,当匹配一次之后,无论匹配多少次结果都不会变,所以我们可以把匹配的结果存入物品NBT中,下次直接从NBT获取数据(我只保存了本地的),此为第一层缓存,也是持久层。
- 持久化的物品匹配结果,读取NBT也消耗性能,可以给读取过NBT的物品添加缓存,key就用hashcode方法,在短时间内这个值是不会变的,我们可以给他设定一个较短的过期时间, 可以应付短时间之内发生的所有缓存查询, 这个频率其实是很高的,命中缓存可以节省大量运算,此为第二层缓存,临时缓存
- P.S. 经过指正得知 ItemStack的hashcode效率不比从nbt读取,所以第二层缓存是不必要的, 但是我懒得找其他例子,你就当作nbt性能远比hashcode差
从缓存访问顺序来讲,我们说的n级缓存其实就是缓存的访问顺序,所以一级缓存应该对应 持久化的物品匹配结果,二级缓存为 物品的匹配结果
代码实现
实际实现
- // EhCache提供的临时缓存API,类似于Map get查询,add添加
- val settingCache: UserManagedCache<ItemStack, BaseSetting> = UserManagedCacheBuilder
- .newUserManagedCacheBuilder(ItemStack::class.java, BaseSetting::class.java) // 缓存KV类型
- .withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofSeconds(3))) //过期时间 3秒
- .withKeyCopier(IdentityCopier())
- .withValueCopier(IdentityCopier())
- .build(true)
- // 获取物品设置, 经过指正得知 ItemStack的hashcode效率不比从nbt读取,所以一级缓存是不必要的
- // 但是这只是个展示多级缓存的例子,你就当作nbt性能远比hashcode差
- fun getSetting(item: ItemStack, setInCache: Boolean = true): BaseSetting {
- // 一级缓存, 由EhCache实现
- var setting: BaseSetting? = settingCache.get(item)
- // 存在缓存,立即返回
- if (setting != null) {
- return setting
- }
- // 二级缓存,从NBT中读取设置的ID,再根据ID获取对应的对象
- val key = NBTEditor.getString(item, *nbtPath)
- // 持久化的ID可能过期,先判断
- if (key != null)
- setting = settings[key]
- // 存在缓存,立即返回
- if (setting != null) {
- settingCache.put(item, setting)
- return setting
- }
- // 物品匹配,顺序查找,找到了立即结束循环并加入缓存
- for ((_, s) in settings) {
- if (s.match(item)) {
- val itemMatchedEvent = ItemMatchedEvent(item, s)
- Bukkit.getPluginManager().callEvent(itemMatchedEvent)
- setting = itemMatchedEvent.matchSetting ?: DefaultItemSetting
- if (setInCache) item.itemMeta = NBTEditor.set(item, setting.keyPath, *nbtPath).itemMeta
- settingCache.put(item, setting)
- return setting
- }
- }
- //到这就没有合适的键了,但又有nbt,说明被删了,清除旧的缓存
- if (key != null) item.itemMeta = NBTEditor.set(item, null, *nbtPath).itemMeta
- settingCache.put(item, DefaultItemSetting)
- return DefaultItemSetting
- }
方块数据过滤器
问题描述
如上面提到的,由于bukkit没有方块的持久化数据接口,所以需要自己外挂数据。我使用的方案是通过方块坐标的字符串来绑定数据
比如 Key = world,20,64,-158 表示world世界里的 20,64,-158坐标 以此为Key
获取方块数据的情况多且频率高,比如 玩家破坏、点击方块、BlockPhysicsEvent、活塞推动方块等一切方块有关的事件我都用了
缓存穿透是最大的问题,有特殊数据的方块是远远小于其他方块的
解决思路
方块数据量是未知的,所以我按照大量来预估,应该具有的缓存如下
- 所有方块数据缓存, 因为量比较大,所以放在磁盘,通过mysql等方式储存, 此为持久层
- 正在访问的方块数据缓存,1tick内发生的方块事件很多,但方块Key发生改变的概率很小,所以可以加个临时缓存
缓存穿透可以使用过滤器,由于方块数据会发生变化(增删查改),所以我选择能删除的布谷鸟过滤器
所以还多了个过滤器的数据缓存
代码实现
- //缓存管理器
- val cacheManager: CacheManager = CacheManagerBuilder
- .persistence(dataFile) //持久化路径 dataFile 为 File类型
- .builder(CacheManagerBuilder.newCacheManagerBuilder())
- .withCache( //配置一个缓存
- "Block-owner", // 一个 CacheManagerBuilder 可以构建多个缓存,这个是识别的id,也是持久化的id
- CacheConfigurationBuilder.newCacheConfigurationBuilder(
- String::class.java, String::class.java, //缓存 KV类型
- ResourcePoolsBuilder.newResourcePoolsBuilder() //资源调度策略,限制缓存大小防止浪费, 这里其实也是3级缓存
- .heap(100, EntryUnit.ENTRIES) // 堆缓存,限制100个实体
- .offheap(10, MemoryUnit.MB) // 堆外缓存 10 M,由nio引入的技术
- .disk(500, MemoryUnit.MB, true) // 磁盘缓存,500M够了 true表示需要持久化
- )
- .withExpiry(ExpiryPolicyBuilder.noExpiration()) // 不过期,当作Redis用,但是有丢数据的风险
- .build()
- )
- //方块缓存
- val blockCache: Cache<String, String> = cacheManager.getCache("Block-owner", String::class.java, String::class.java)!!
- // 过滤器缓存,也就是存放过滤器的数据 用的是 https://github.com/bdupras/guava-probably
- // 需要额外Guava库,bukkit自带
- // 数据储存路径,用的是二进制存储,什么后缀不重要,没有都可以
- private val filterFile = File(BukkitTemplate.getPlugin().dataFolder, "data${File.separator}Filter-Block")
- // 过滤器对象,从文件读取,没有就创建新的, 里面的 20480, 0.03 是算法的参数, 20480是预估的数据大小,0.03是预估的出错概率
- private val blockFilter: CuckooFilter<CharSequence> = if (!filterFile.exists()) CuckooFilter.create(
- Funnels.stringFunnel(StandardCharsets.UTF_8), 20480, 0.03
- )
- else filterFile.inputStream().use { CuckooFilter.readFrom(it, Funnels.stringFunnel(StandardCharsets.UTF_8)) }
- //添加方块缓存key是方块的字符串,比如world,20,64,-158, 过滤器和方块缓存都添加
- fun addBlock(key: String, value: String) {
- blockCache.put(key, value)
- blockFilter.add(key)
- }
- //删除方块缓存,过滤器和方块缓存都删除
- fun removeBlock(str: String) {
- blockFilter.remove(str)
- blockCache.remove(str)
- }
- // 获取方块数据 参数是方块的字符串Key,比如world,20,64,-158 返回的是Key和对象的集合
- fun getBlockInfo(str: String): Pair<String, ItemSetting>? {
- //如果过滤器判断不存在,那就是一定不存在
- if (!blockFilter.contains(str)) return null
- //根据设置有3%的概率误判,所以缓存的查询结果也可能不存在
- val get = blockCache.get(str) ?: return null
- //之后是解析Key字符串获取设置对象的,不用管
- val split = get.split(',')
- return split[0] to ItemSettings.getSetting(split.getOrNull(1))
- }
- //保存方块、过滤器数据
- fun onSave() {
- //过滤器数据
- if (!filterFile.exists()) {
- filterFile.createNewFile()
- }
- filterFile.outputStream().use {
- blockFilter.writeTo(it)
- }
- //关闭所有缓存,并进行缓存持久化
- cacheManager.close()
- }
结语
本文简述了数据的缓存与在改善性能方面的应用,为个人的经验总结,存在不足与缺失,如有错误请不吝指出。
赞同楼主的观点
想请教一下用hashCode来作为缓存的key,是如何处理哈希碰撞的情况的呢?
本帖最后由 结冰的离季 于 2023-3-20 00:18 编辑
你指的是什么情景呢?
如果是指过滤器算法,比如布隆过滤器
简单来说它的数组长度很大,几万百万都可能,这主要看你的数据量,而且每个key都会经过多个不同的哈希算法得到一个值,再对数组长度取余,得到多个点,哈希碰撞是可能的,当数据足够多时碰撞概率也增大,所以说过滤器算法有一个错误率。
过滤器算法基本都没有主动规避哈希碰撞,而是尽量减少,与Java的HashMap不同,过滤器算法需要足够的性能和最小的空间来应付高并发,所以也无力处理哈希碰撞,
gooding300 发表于 2023-3-19 23:41
想请教一下用hashCode来作为缓存的key,是如何处理哈希碰撞的情况的呢?
你指的是什么情景呢?
如果是指过滤器算法,比如布隆过滤器
简单来说它的数组长度很大,几万百万都可能,这主要看你的数据量,而且每个key都会经过多个不同的哈希算法得到一个值,再对数组长度取余,得到多个点,哈希碰撞是可能的,当数据足够多时碰撞概率也增大,所以说过滤器算法有一个错误率。
过滤器算法基本都没有主动规避哈希碰撞,而是尽量减少,与Java的HashMap不同,过滤器算法需要足够的性能和最小的空间来应付高并发,所以也无力处理哈希碰撞,
本帖最后由 耗子 于 2023-3-20 00:22 编辑
看了一下,发现你开发的绑定插件根本不需要使用缓存。
1. 使用hashCode作为缓存的键,这极有可能发生哈希碰撞。
2. ItemStack不是不可变的对象,hashCode会发生变化。
3. 获取ItemStack的hashCode是非常耗时且不会被缓存的。
以上我建议将代码更改为:
复制代码
看了一下,发现你开发的绑定插件根本不需要使用缓存。
1. 使用hashCode作为缓存的键,这极有可能发生哈希碰撞。
2. ItemStack不是不可变的对象,hashCode会发生变化。
3. 获取ItemStack的hashCode是非常耗时且不会被缓存的。
以上我建议将代码更改为:
- fun getSetting(item: ItemStack): BaseSetting {
- // 获取nbt中缓存的设置的键
- val key = NBTEditor.getString(item, *nbtPath)
- if (key != null)
- return settings[key]
-
- // 物品匹配,顺序查找,找到了立即结束循环
- for ((_, s) in settings) {
- if (s.match(item)) {
- // 触发事件,提供接口修改匹配的对象
- val itemMatchedEvent = ItemMatchedEvent(item, s)
- Bukkit.getPluginManager().callEvent(itemMatchedEvent)
- setting = itemMatchedEvent.matchSetting ?: DefaultItemSetting
- item.itemMeta = NBTEditor.set(item, setting.keyPath, *nbtPath).itemMeta
- return setting
- }
- }
-
- item.itemMeta = NBTEditor.set(item, null, *nbtPath).itemMeta
- setting = setting ?: DefaultItemSetting
- return setting
- }
本帖最后由 结冰的离季 于 2023-3-20 00:52 编辑
首先,感谢提出建议
第一点,使用整形作为缓存的键确实可能出现哈希碰撞,但HashMap有处理策略,比如链表和红黑树,最终将会比较hashcode的值。我这种情况下,我设置了过期时间是3秒,还可以更少,也就说明缓存中的key不可能无限增加,在短时间内假设缓存中有1w个物品对象,hashcode 有32位那么哈希碰撞的概率 1w/4.2亿 大概是 2.328e-6 即 0.0002328%,我觉得我承担得起这个概率
第二点 同上,我觉得短时间内他不会变,即使会重新获取二级缓存就行
第三点我没考虑过,如果 ItemStack的hashCode 很耗时我会考虑自己实现一个hashcode算法
DefaultItemSetting确实是单例,用object声明了
for中不直接返回确实造成了一定的重复代码,我会改正
耗子 发表于 2023-3-20 00:18
看了一下,发现你开发的绑定插件根本不需要使用缓存。
1. 使用hashCode作为缓存的键,这极有可能发生哈希 ...
首先,感谢提出建议
第一点,使用整形作为缓存的键确实可能出现哈希碰撞,但HashMap有处理策略,比如链表和红黑树,最终将会比较hashcode的值。我这种情况下,我设置了过期时间是3秒,还可以更少,也就说明缓存中的key不可能无限增加,在短时间内假设缓存中有1w个物品对象,hashcode 有32位那么哈希碰撞的概率 1w/4.2亿 大概是 2.328e-6 即 0.0002328%,我觉得我承担得起这个概率
第二点 同上,我觉得短时间内他不会变,即使会重新获取二级缓存就行
第三点我没考虑过,如果 ItemStack的hashCode 很耗时我会考虑自己实现一个hashcode算法
DefaultItemSetting确实是单例,用object声明了
for中不直接返回确实造成了一定的重复代码,我会改正
结冰的离季 发表于 2023-3-20 00:49
首先,感谢提出建议
第一点,使用整形作为缓存的键确实可能出现哈希碰撞,但我这种情况不同,我设置了过 ...
我的意思是,你直接用NBT缓存一下配置键就够了,整这么多胡里花哨的干啥