如何用内存做缓存(写入缓存与windows内存)?如果你对这个不了解,来看看!
Java中的内存映射缓存区是什么?,下面是编程技术汇给大家的分享,一起来看看。
如何用内存做缓存
Java 中的内存映射缓存区(Memory-mapped buffer)是一种将文件或文件的一部分直接映射到程序内存中的技术。简单来说,内存映射缓存区允许 Java 程序在处理文件时像处理一个非常大的字节数组一样进行操作,而不用担心过多的 I/O 负担或频繁的磁盘访问。为了更好地理解内存映射缓存区,我将从底层实现和使用场景两个方面进行说明。
内存映射缓存区的原理: 在传统的 I/O 模型中,应用程序必须通过 File 和 InputStream(或 Reader)或 OutputStream(或 Writer)对象来访问文件数据。这通常需要调用许多系统调用来打开文件、寻找所需数据等,并且可能会导致频繁的磁盘 I/O 操作。对于大型文件来说,这些额外开销将极大地影响程序性能。
相比之下,内存映射缓存区提供了一种更高效、更便捷的调用文件数据的方法。它利用了虚拟内存管理机制,让操作系统将一部分磁盘文件映射到进程地址空间的一块连续区域当中。操作系统负责管理内存页的加载和卸载,而 Java 程序只需要访问这块内存区域即可。因此,当程序访问映射的缓冲区时,操作系统负责缓冲区的管理和数据的传输,从而避免了频繁的磁盘 I/O 操作和多余的系统调用。
实现方式:
在 Java 中使用内存映射缓存区需要借助于 NIO(New IO)库中的 MappedByteBuffer 类。具体而言,实现内存映射缓存区可以分为以下几个步骤:
1、使用 FileChannel 类打开所需文件,并将其与一个 MappedByteBuffer 对象相关联。
2、使用 MapMode 枚举定义所需的内存映射模式,包括 “READ_ONLY”、“READ_WRITE” 和 “PRIVATE” 三种。
3、调用 MappedByteBuffer 的 load 方法将文件区域加载到内存中,或者使用 force 方法确保所有的修改都已经被写回磁盘。
4、通过 position()、limit() 和 capacity() 方法操作缓冲器中的数据,也可以直接调用 get() 或 put() 方法获取或设置数据。
内存映射缓存区通常适用于以下场景:
1、大型文件处理:当需要读取超大型文件(如几百 GB 或几 TB 大小的文件)时,传统的 I/O 方法可能会导致频繁的磁盘 I/O 和系统调用,而内存映射缓存区可以将整个文件的内容作为一个连续的字节数组一次性地加载到内存中,从而大大提高读写文件的效率。
2、多进程共享:当多个进程需要共享某个文件的数据时,内存映射缓存区可以在不同的进程之间共享相同的虚拟内存。这种方法使得程序只需要将文件映射到虚拟地址空间中一次,然后就可以在进程之间共享这块内存了,避免了复制出多份相同的数据。
3、IO 的优化:内存映射缓存区提供了一种更加有效的方式来管理磁盘文件和读写操作。在像 Web 系统或数据库服务器这样涉及到较大量的数据读写的场景下,使用内存映射缓存区可以带来更高的效率。
在 Java 中,内存映射缓存区是一种高效、方便的技术,通过将文件映射到进程地址空间中的虚拟内存区域,Java 程序可以像处理一个非常大的字节数组一样进行操作。内存映射缓存区非常适用于读取超大型文件、多进程共享以及 IO 优化等场景,能够大大提高程序的性能与效率。
写入缓存与windows内存
前言在日常开发中,缓存是提升系统瓶颈的最简单方法之一,如果缓存使用得当,缓存可以增加系统吞吐量,减少响应时间、减少数据库负载等。
但在不同的场景下,所适用缓存读写策略是不尽相同的,这篇文章将介绍不同缓存读写策略在不同场合下的使用与存在问题的分析,并给出解决方案~
Cache Aside PatternCache Aside Pattern 意为旁路缓存模式,是我们平时最常用的一个缓存读写模式,应用程序需要一起操作 DB 和 Cache,并且以 DB 的数据为准。 下面我们来看一下这个模式下的缓存读写步骤。
方案写更新 DB删除 Cache读从 Cache 中读取数据,有数据直接返回Cache中没数据的话,从 DB 中读取数据,数据更新到 Cache 中后返回。代码实施public class DataCacheManager { public Data query(Long id) { String key = "keyPrefix:" + id; //查询缓存 Data data = cacheService.get(key); if(data == null) { //查询DB data = dataDao.get(id); //更新缓存 cacheService.set(key, data); } return data; } public Data update(Data data) { //更新DB data = dataDao.update(data); //更新缓存 String key = "keyPrefix:" + data.getId(); cacheService.del(key); }}复制代码在工作中的时候,我的应用分层是按照《阿里巴巴Java开发手册》进行的,如下图所示。所以我一般会将缓存的这块逻辑单独抽离,介于Dao层和Service层之间的Manager中实现,这样多个Service通常可以复用这样的缓存代码。
存在的问题看完上面的方案,可能你心里会有一些疑惑,为什么是删除缓存,而不是更新缓存?为什么是先更新DB,再删除Cache,可以交换下顺序吗?按照Cache Aside Pattern的实现,先更新DB,后删除Cache就一定没有问题了吗?我们一个个来分析。
1.为什么是删除缓存,而不是更新缓存?从直观的角度上来看,更新操作,直接把缓存一起更新了应该是个更容易理解的方案。但从性能和安全的角度上来看,直接更新缓存就不一定是合理的了。
性能对于一些比较大的key,更新Cache比删除Cache更耗费性能。甚至当写操作比较多时,可能会存在刚更新的缓存还没有被读过,又再次被更新的情况(这常被称为缓存扰动),导致缓存利用率不高。所以,基于懒加载的思想,不用就没必要存在,所以Cache Aside更支持直接del。
实际上,一般key也不会太大,而且我在生产中使用缓存的场景读请求流量也不会低,所以对于性能的影响个人感觉还好。
安全在并发场景下,多个写请求同时更新缓存可能会造成数据不一致的问题,看下面这个过程:
写请求1
写请求2
读请求3
更新数据A到DB
更新数据B到DB
更新数据B到Cache
更新数据A到Cache
查询Cache数据,读取到A
由于线程调度等原因,写请求 1 的更新Cache操作晚于写请求 2 中的更新Cache操作,这样会导致最终写入缓存中的是来自写请求 1 的数据A,从而使得后面的读操作读取到的都是旧值。
2.为什么是先更新DB,再删除Cache,可以交换下顺序吗?同样地,先删除Cache,再更新DB,也可能会造成DB数据和Cache数据的不一致。为什么呢?看下面这个过程:
写请求1
读请求2
读请求3
删除Cache数据A
查询Cache数据A不存在
从DB读取数据A
更新数据A到Cache
更新数据B到DB
查询Cache数据,读取到A
当多个请求并发时,写请求1的两个操作之间穿插了请求2的所有操作(主要是写DB比较慢,需要分配更多的时间片才能执行完成),导致Cache的数据没有正确更新。只有等下次更新或者缓存自动过期后才会把最新的数据B存入缓存,如果是更新则有可能再次发生这样的问题,导致一直不一致...
3.按照Cache Aside Pattern的实现,先更新DB,后删除Cache就一定没有问题了吗?理论上来说还是可能会出现数据不一致性的问题,看下面这个过程:
请求1
请求2
请求3
查询Cache,不存在
从DB读取数据A
更新数据B到DB
删除Cache数据A
更新数据A到Cache
查询Cache,读取到数据A
同样可能由于线程调度等原因,读请求 1 的更新Cache操作晚于写请求 2 中的删除Cache操作,这样会导致最终写入缓存中的是来自请求 1 的旧值A,而写入数据库中的是来自请求 2 的新值B,即缓存数据落后于数据库,此时再有读请求 3 命中缓存,读取到的便是旧值A。
但与之前不同的是,这种场景出现的概率要小许多,因为更新DB所需的线程调度时间要远大于更新Cache,所以一般情况下都是Cache先执行完成。
4.Cache Aside Pattern如何完全杜绝数据不一致问题?两个字,加锁,单机加JVM锁,集群加分布式锁。
对于写操作,需要将更新DB和删除Cache锁住,
对于读操作,需要将查询Cache不存在之后的操作锁住。
并且需要注意读操作和写操作的锁需要使用一把!!!即读操作没有命中缓存的时候不能进行写操作,反之同理。
来优化下之前的代码,假设是集群环境,使用分布式锁。
public class DataCacheManager { public Data query(Long id) { String key = "keyPrefix:" + id; //查询缓存 Data data = cacheService.get(key); if(data == null) { try { cacheService.lock(key, 2); //查询DB data = dataDao.get(id); //更新缓存 cacheService.set(key, data); } finally { cacheService.unLock(key); } } return data; } @RedisLock(keySuffix = "#data.id", keyPrefix = "keyPrefix:") public Data update(Data data) { //更新DB data = dataDao.update(data); //更新缓存 String key = "keyPrefix:" + data.getId(); cacheService.del(key); }}复制代码注:对@RedisLock不熟悉的推荐参考下巧用 分布式锁 - 掘金,能更简单的使用分布式锁~
不过,加锁势必会影响性能,导致系统吞吐量下降,并发高时可能还会造成堵塞线程过多从而OOM。
5.Cache Aside Pattern如何尽量降低数据不一致的影响?既然加锁会降低性能,如果能接受短暂时间的数据不一致场景,应该怎么尽量降低其影响呢?
解决办法就是更新Cache的同时给Cache增加一个比较短的过期时间,这样可以保证即使数据不一致的话影响也比较小。
但如果在短时间内,有大量缓存同时过期,导致大量的请求直接查询数据库,从而对数据库造成了巨大的压力,严重情况下可能会导致数据库宕机,这种情况叫做缓存雪崩。缓存雪崩的解决方案稍后再讨论,接着往下看~
6.Cache Aside Pattern首次读请求问题对于第一次读取的Cache,数据一定不在Cache中,如果服务发布后一下来很多个读请求,很可能同时绕过if(data == null)判断条件从而一起请求DB,造成压力过大。
如何解决这个问题呢?
可以将热点数据提前加载到Cache中,比如读取配置获取热点数据Key,然后使用@PostConstruct注解在服务启动之前加载数据。
public class DataCacheManager { @PostConstruct public void init() { //假设配置的热点id为520,666 List<Long> hotIds = Lists.newArrayList(520L, 666L); for (Long hotId : hotIds) { Data data = dataDao.get(hotId); //更新缓存 cacheService.set(key, data, 60); } } //...}复制代码适用场景由上述分析,该方案适合读多写少的场景,并且尽量使用在对数据一致性要求没有那么高的场景,例如商品详情页的商品数据缓存,商品描述和价格等信息变化的频次一般都很低,即使价格有变化,在下单的时候订单系统会读取最新的商品价格,确保数据准确。
Read Through PatternRead-Through 意为读穿透模式,它的流程和 Cache-Aside 读操作基本类似,不同点在于 Read-Through 中多了一个访问控制层,应用读请求只和访问控制层进行交互,而背后缓存命中与否的逻辑则由访问控制层与数据源进行交互。
这样做可以使业务层的实现会更加简洁,并且对于缓存层及持久化层的交互封装做得更好,可以更轻松的扩展和迁移。
方案应用程序读请求访问控制层访问控制层从 Cache 中读取数据,读取到就直接返回。读取不到的话,先从 DB 加载,写入到 Cache 后返回。举个例子,著名的本地缓存Guava Cache采用的就是该模式。
//初始化LoadingCache<String, Data> loadingCache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterAccess(1, TimeUnit.MINUTES) .build( new CacheLoader<String, Data>() { public Data load(String key) { return dataDao.query(key); } } );//读操作,里面包含了 获取缓存-如果没有-则计算"[get-if-absent-compute]的原子语义loadingCache.get(key);复制代码实际上,在Cache Aside Pattern 中的实现上我们通过将缓存的逻辑抽离到Manager层中,一定程度上也算是勉强达到了降低应用层复杂度的效果(主要是将Service层当作了应用层)。
适用场景该方案适合读请求多的场景,并且对数据一致性要求没有那么高的场景。另外,该方案也同样存在首次读取问题,可以在初始化时模拟外部读请求使数据能提前加载。
Write Through PatternWrite-Through 意为写穿透模式,它也增加了访问控制层来提供更高程度的封装。不同于 Cache-Aside 的是,Write-Through 直写模式在写请求更新Cache之后,更新DB,并且这两步操作需要控制层保证是一个原子操作。
方案应用程序请求访问控制层访问控制层 更新Cache同步更新DB存在的问题1.为什么先更新Cache,再更新DB?这里顺序关系不大,不管是先更新Cache还是先更新DB,都可能存在之前Cache Aside问题一中提过的脏数据问题。解决办法就是问题四的方式,通过加锁解决并发问题。
2.如何保证两步操作的原子性?俊峰之前也没尝试过这种方案,但可以提出一些自己的想法~
如果更新Cache失败了,由于是第一步,可以返回异常让客户端重试。
如果是更新DB失败了,需要看怎么设计,如果希望客户端重试的话,可以把更新Cache回滚。如果不希望客户端重试,可以把失败的请求发送到消息队列中,然后消费该消息补偿失败。
适用场景Write Through通常会和Read Through一同使用,满足读写需求。该方案主要适用于写请求比较多的场景,并且对数据一致性要求较高的场景,比如银行系统。
俊峰平时在开发过程很少见到有项目使用Write Through方案,除了必须保证更新Cache和更新DB的原子性会造成一定性能方面的影响外,最主要的是缓存服务的封装是比较难实现的,或者可以接入一些现成的框架,比如Redis官网推荐的RedisGears。
Write Behind Pattern(异步缓存写入)Write Behind 又叫 Write Back,意为异步回写模式。它与Read-Through/Write-Through 一样,具有类似的访问控制层提供到应用程序。不同的是,Write behind 在处理写请求时,只更新Cache后就返回,对于数据库的更新,则是通过批量异步更新的方式进行的,批量写入的时间点可以选在数据库负载较低的时间进行。
方案应用程序请求访问控制层访问控制层 更新Cache异步更新DB存在的问题1.异步相比Write Through带来了哪些好处和问题?在 Write-Behind 模式下,由于不用同步更新DB,写请求延迟大大降低,并减轻了数据库的压力,具有较好的吞吐性。
但数据库和缓存的一致性较弱,比如当更新的数据还未被写入数据库时,直接从数据库中查询数据是落后于缓存的。同时,缓存的负载较大,如果缓存宕机会导致数据丢失,所以需要做好缓存的高可用(以后会介绍~)。
适用场景显然,根据上面的分析,Write behind 模式下非常适合大量写操作的场景,比如电商秒杀场景中库存的扣减。
在之前的文章——如何选择一个合适的库存扣减方案?中我们提到过Redis配合lua脚本的方案,实际上和Write Behind思想是一致的,都是先更新Cache,后异步更新DB,只是没有单独封装访问控制层。
总结四种缓存方案各有优缺点,这也印证了那句老话,没有最完美的方案,只有最适合的方案。
俊峰在实际项目中使用的最多的就是Cache Aside(用于Redis),有时候会结合Read Through(用于本地缓存guava)构成二级缓存,后面会单独写一篇文章介绍~
作者:史俊峰在搬砖链接:https://juejin.cn/post/7124541481259368462