缓存
在程序中,缓存是一个高速数据存储层,其中存储了数据子集,且通常是短暂性存储,这样日后再次请求此数据时,速度要比访问数据的主存储位置快。通过缓存,可以高效地重用之前检索或计算的数据。
为什么要使用缓存
场景
在Java应用中,对于访问频率高,更新少的数据,通常的方案是将这类数据加入缓存中,相对从数据库中读取,读缓存效率会有很大提升。
在集群环境下,常用的分布式缓存有Redis、Memcached等。但在某些业务场景上,可能不需要去搭建一套复杂的分布式缓存系统,在单机环境下,通常是会希望使用内部的缓存(LocalCache
)。
Java内存缓存-通过Map定制简单缓存 - 云+社区 - 腾讯云 (tencent.com)
本地缓存和分布式缓存
在本项目中,由于三级菜单的查询是很常见的操作,有由于我们菜单的数据变动的机会很少,而且优化业务逻辑之后,吞吐量并没有的到显著的提高,此时我们就可以考虑使用缓存。
将我们的菜单数据,都放在缓存中,我们每次获取三级菜单数据的时候,就可以减少对数据库的访问,主要是来减少I/O的消耗,我们每次访问数据库,都有需要建立TCP的连接的(虽然有线程池),但是TCP它是需要三次握手和四次挥手,依旧还是挺浪费我们的时间的。
基于此,我们要考虑使用缓存来保存我们的菜单数据,来减少性能的消耗。
缓存使用
为了系统的整体的提升,我们一般都会将部分数据放入到缓存中,加速访问。而数据库承担数据落盘的工作。当然,缓存的使用,是需要有一定的条件。
那些数据适合做缓存来使用呢?
- 及时性、数据一致性要求不高的。
- 访问量大且更新频率不高的数据(读多,写少)
常用的应用场景:
- 电商类的应用,商品分类、商品列表等适合缓存并加一个失效时间(根据数据的更新的频率),后台如果发布一个商品,买家需要5分钟才能看到新的商品一般还是可以接受的。
缓存的使用方式:
对应的伪代码:
data = cache.loadd(id);//从缓存中读取数据 if(data==null){ data = db.loadd(id);//从数据库中加载 cache.put(id,data);//保存到缓存中 } return data;
需要注意的是:
在开发中,凡是放入缓存中的数据我们应该指定过期时间,使其可以在系统中即使没有主动更新数据也能自动触发数据加载进入缓存的流程。避免业务崩溃导致数据永久不一致的问题。
缓存的方式
使用Map<String,Object>
。
Java 利用Map实现缓存 - zhoupan - 博客园 (cnblogs.com)
- 缓存工具类
package com.zsplat.yyzx.util; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 缓存机制 */ public class CacheUtil { private static CacheUtil cacheUtil; private static Map<String,Object> cacheMap; private CacheUtil(){ cacheMap = new HashMap<String, Object>(); } public static CacheUtil getInstance(){ if (cacheUtil == null){ cacheUtil = new CacheUtil(); } return cacheUtil; } /** * 添加缓存 * @param key * @param obj */ public void addCacheData(String key,Object obj){ cacheMap.put(key,obj); } /** * 取出缓存 * @param key * @return */ public Object getCacheData(String key){ return cacheMap.get(key); } /** * 清楚缓存 * @param key */ public void removeCacheData(String key){ cacheMap.remove(key); } }
- 写一个定时器 定时从数据库里查出数据添加到缓存中
CacheUtil.getInstance().addCacheData("cacheYYZS100New", cacheMap);
- 取出缓存
Map<String , Object> cacheMap = (Map<String, Object>)CacheUtil.getInstance().getCacheData("cacheYYZS100New"); 存缓存和取缓存的方法,知道类型的话,强制转换都没问题
- 分页 捋清楚startIndex和pageSize,count之间的关系就行 用截取list的方法实现分页
if(StringUtils.isNotEmpty(startIndex)&&StringUtils.isNotEmpty(pageSize)){ Integer start = Integer.parseInt(startIndex); Integer size = Integer.parseInt(pageSize); if(size*(start+1)<resultCount){ dataList = dataList.subList(start*size,size*(start+1)); }else if(start*size<resultCount){ dataList = dataList.subList(start*size,resultCount); }else{ dataList = new ArrayList<>(); } }
在本项目中使用的流程图:
在单体的项目中,这中方式确实能解决一定的问题,但是我们现在的项目都是部署在多台的服务器上,也就是分布式,就是下面这种架构图:
但是这样会存在一个问题,假设现在有这一个场景,我们的商品服务,部署在3台服务器中,用户发起请求,负载均衡到我们的额一号服务器上,去获取菜单数据,发现缓存为空,就去数据库中查询,查询完之后,也给缓存中放一份,但是呢,又有其他人发起请求,负载均衡到了2号服务器,修改了菜单数据,又放在了2号服务器的缓存中,此时就会出现,1号服务器中的缓存数据和数据库中就不一致了,那为什么呢?在我们的业务代码中是判断缓存有没有,1号服务器之前已经查询到数据了,并且放在自己的缓存中,那下一次在来查询还是走一样的缓存。此时就会出现数据库和缓存中数据不一致的情况。
此时就需要针对分布式下如何使用缓存,来做进一步的思考了。
解决分布式下缓存和数据库数据不一致的问题
当然针对于缓存,我们需要有一个共识:
- 缓存必须要有过期时间
- 保证数据库跟缓存的最终一致性即可,不必追求强一致性
分布式下缓存的解决方案的架构图:
![]()
在分布式下的缓存,我们应该使用一个中间件,将所有商品服务(当然还有很多的服务)的缓存都放到缓存的中间件Redis。
商品服务的部署的其他服务器,都给一个地方(缓存中间件)放数据。而不是放在自己的服务进程中,而是让大家来共享这个缓存。
这个缓存中间件有很多,Memcache、Redis等等。
之所以选择缓存中间件,是因为,如果我们后期的数据很大,我们的缓存中间件,放不下,我们可以对缓存中间件,搭集群,将数据分片存储(也就是一人分一点)。理论上我们的缓存的容量的无限提升,打破了我们本地缓存的局限性。
整合Redis(作为我们各个微服务的缓存中间件)
整合的步骤:
- 导入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--redis版本有管理-->
- 配置Redis服务器
spring: redis: host: 192.168.1.115 port: 6379
- 如何使用Redis(单元测试)
//Redis实际上也是Map<key,value> @SpringBootTest @Slf4j class RedisTest { @Autowired StringRedisTemplate stringRedisTemplate; /** * 测试stringRedisTemplate * * @author wanglufei * @date 2022/5/11 9:27 PM */ @Test public void test01() { ValueOperations<String, String> ops = stringRedisTemplate.opsForValue(); //存值的操作 ops.set("uin", "niupi_" + UUID.randomUUID()); //查询操作 System.out.println(ops.get("uin")); } }
接下来就使用Redis来保存的我们菜单数据。
改造三级分类的业务(使用Redis来存储我们的菜单数据)
业务逻辑
@Override public Map<String, List<Catalog2Vo>> getCatalogJson() { //1.加入redis缓存 缓存中存的数据都是json数据格式 //json数据跨平台 兼容性好 String catalogJson = stringRedisTemplate.opsForValue().get("catalogJson"); if (StringUtils.isEmpty(catalogJson)) { //2.如果缓存中没有的话 就去数据库查询 Map<String, List<Catalog2Vo>> fromDB = getCatalogJsonFromDB(); //将从数据库查询出来的数据转为json放到缓存中 stringRedisTemplate.opsForValue().set("catalogJson", JSON.toJSONString(fromDB)); } //需要注意的是 给缓存中放的是json数据 但是我们要返回给前台的是对象 所以还要转换过来 //其实这个操作也就是序列化和反序列化的过程 Map<String, List<Catalog2Vo>> result = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catalog2Vo>>>() { }); return result; }
针对改造的三级分类压测
编写测试计划
和上面的步骤类似。我直接将线程组的配置拉倒了5000一直循环,不到一会儿,就出现异常,JMeter卡死。
压测过程出现的问题
高并发下缓存失效-缓存穿透
后台服务也出现了连接超时的异常,出现的原因的是我们这一下估计好几十w的请求,直接将我们的服务的打崩了。缓存直接给穿透了,将大量的请求落到我们的MySQL服务器上。
产生堆外内存溢出:OutOfDirectMemoryError
SpringBoot2.0之后默认使用Lettuce作为操作redis的客户端,它使用netty进行网络通信。主要是Lettuce的客户端的问题导致的我们的堆外内存溢出。
解决方案:【性能调优】堆外内存溢出_种下星星的日子的博客-CSDN博客_lettuce 内存溢出
先来解释一下,什么是缓存穿透?
缓存穿透,指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也没有此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次查询都要到存储层去查询,失去了缓存的意义。
这样的操作会有很大风险,容易被人利用不存在数据进行攻击,数据库瞬时压力增大,最终导致崩溃。
我们知道,缓存的工作原理是先从缓存中获取数据,如果有数据则直接返回给用户,如果没有数据则从慢速设备上读取实际数据并且将数据放入缓存。同步缓存就像这样:
高并发下缓存失效-缓存雪崩
缓存雪崩是指我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部落到我们的数据库上,数据库瞬时压力过重雪崩。
解决的办法,在原有的失效时间基础上增加一个随机值,比如1-5分钟的随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
高并发下缓存失效-缓存击穿
对于一些设置了过期时间的key,如果这些key 可能会在某些时间点被超高并发地访问,是一种非常热点的数据。如果这个key在大量的请求同时进来前正好失效,那么所有对这个key的数据查询都落到db,我们称之为缓存击穿。
解决的办法:第一种是加锁。大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去DB。
分布式下如何加锁
本地锁
本地锁的意义是,在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行,以防止并发修改变量带来数据不一致或者数据污染的现象。
而为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
常用的本地锁
synchronized
和lock
。
本地锁,对于普通同步方法,锁是当前实例对象。它等同于同步方法块,锁是synchronized
括号里的配置的对象。
对于静态同步方法,锁是当前类的Class类元信息,类似于字节码。
使用本地锁(在多线程的情况下)

所以在本项目中,使用本地锁来控制资源,如果在我们分布式的环境下,会出现什么问题?
发现在每个服务,它都会去查询数据库,不是我们预想的结果,所以我们要考虑使用分布式锁。
分布式锁
https://zhuanlan.zhihu.com/p/42056183
如果是单机情况下(单JVM),线程之间共享内存,只要使用线程锁就可以解决并发问题。
但如果是分布式情况下(多JVM),线程A和线程B很可能不是在同一JVM中,这样线程锁就无法起到作用了,这时候就要用到分布式锁来解决。
分布式锁是控制分布式系统同步访问共享资源的一种方式。
在了解,什么是分布式锁的基础上,我们需要对锁有一个概念。
什么情况下用锁
在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。
而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
不同地方实现锁的方式也不一样,只要能满足所有线程都能看得到标记即可。
如 Java 中 synchronized
是在对象头设置标记,Lock
接口的实现类基本上都只是某一个 volitile
修饰的 int
型变量其保证每个线程都能拥有对该 int
的可见性和原子修改,linux 内核中也是利用互斥量或信号量等内存数据做标记。
除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满足在对标记进行修改能保证原子性和内存可见性即可。
什么是分布式
分布式的 CAP 理论告诉我们:
任何一个分布式系统都无法同时满足一致性(
Consistency
)、可用性(Availability
)和分区容错性(Partition tolerance
),最多只能同时满足两项。
目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。
基于 CAP理论,很多系统在设计之初就要对这三者做出取舍。
在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
分布式场景
此处主要指集群模式下,多个相同服务同时开启.
在许多的场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。
很多时候我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,通过 Java 提供的并发 API 我们可以解决,但是在分布式环境下,就没有那么简单啦。
- 分布式与单机情况下最大的不同在于其不是多线程而是多进程。
- 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。

什么是分布式锁?
- 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
- 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。(我觉得分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠。。。一个大坑)
- 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。
我们需要怎样的分布式锁?
- 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
- 这把锁要是一把可重入锁(避免死锁)
- 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
- 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
- 有高可用的获取锁和释放锁功能
- 获取锁和释放锁的性能要好
数据库实现分布式锁
- 基于乐观锁
就是说特别乐观,比如说每次去吃饭的时候,都认为窗口没有人,只有到了吃饭的窗口才看有没有人,如果有人则去别的地方吃饭。
就像系统认为数据的更新在大多数情况下是不会产生冲突的, 只在数据库更新操作的提交的时候才对数据作冲突检测。
如果检测的结果出现了与预期数据不一致的情况,则返回失败的信息。
乐观锁在大多数是基于数据版本(version)的记录机制实现的。
为了更好的理解数据库乐观锁在实际项目中的使用,下面举一个典型的电商库存的例子。
当用户进行购买的时候就会对库存进行操作(库存减1代表已经卖出了一件)。
我们将这个库存模型用下面的一张表
optimistic_lock
来表述,参考如下:CREATE TABLE `optimistic_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `resource` int(11) NOT NULL COMMENT '锁定的资源', `version` int(11) NOT NULL COMMENT '版本信息', `created_time` datetime DEFAULT NULL COMMENT '创建时间', `updated_time` datetime DEFAULT NULL COMMENT '更新时间', `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', PRIMARY KEY (`id`), UNIQUE KEY `uiq_idx_resource` (`resource`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
其中:
id
表示主键;resource
表示具体操作的资源,在这里也就是特指库存;version
表示版本号。在使用乐观锁之前要确保表中有相应的数据,比如:
INSERT INTO `database1`.`optimistic_lock`(`id`, `resource`, `version`, `created_time`, `updated_time`, `deleted_time`) VALUES (1, 100, 1, '2020-04-10 22:05:52', '2020-04-10 22:05:52', '2020-04-10 22:05:52');
如果是单线程进行操作,数据库本身就能保证操作的正确性。主要步骤如下:
- STEP1 - 获取资源:
SELECT resource FROM optimistic_lock WHERE id = 1
- STEP2 - 执行业务逻辑
- STEP3 - 更新资源:
UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1
然而大多数情况下是不会单线程的,要是单线程的话 ,公司岂不是要凉凉。
比如两个以上的线程购买同一件商品,在数据库层实际操作的时候应该是库存(
resource
)减2,但是由于是高并发的情况,第一个线程执行之后(执行了STEP1、STEP2但是还没有完成STEP3),第二个线程在购买相同的商品(执行STEP1),此时查询出的库存并没有完成减1的动作,那么最终会导致2个线程购买的商品却出现库存只减1的情况。在引入了version字段之后,那么具体的操作就会演变成下面的内容:
- STEP1 - 获取资源:
SELECT resource, version FROM optimistic_lock WHERE id= 1
- STEP2 - 执行业务逻辑
- STEP3 - 更新资源:
UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion
其实,借助更新时间戳(
updated_at
)也可以实现乐观锁,和采用version
字段的方式相似:更新操作执行前线获取记录当前的更新时间,在提交更新时,检测当前更新时间是否与更新开始时获取的更新时间戳相等。
乐观锁的优点:
- 检测数据冲突时不依赖数据库库本身的机制,所以不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。
乐观锁的缺点:
- 对表的设计增加额外的字段,增加了数据库的冗余,另外当并发量高的时候,version的值在频繁变化,则会导致大量请求失败,影响系统的可用性。
综上所述,乐观锁比较适合并发量不高,并且写操作不频繁的场景
- 基于表主键唯一做分布式锁
利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。
上面这种简单的实现有以下几个问题:
- 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
- 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
- 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。
当然,我们也可以有其他方式解决上面的问题。
- 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
- 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
- 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
- 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
- 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
- 比较好的办法是在程序中生产主键进行防重。
- 基于 Redis 做分布式锁
流程图:
![]()
/** * 使用分布式锁 * 1.使用setnx */ public Map<String, List<Catalog2Vo>> getCatalogJsonFromDBWithRedisSetnx() { /** * 怎么使用分布式锁 * 1.占坑 */ //Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111"); /** * 5.删除锁直接删除??? 如果由于业务时间很长,锁自己过期了,我们 直接删除,有可能把别人正在持有的锁删除了。 * 解决办法:占锁的时候,值指定为uuid,每个人匹配是自己 的锁才删除。 */ String token = UUID.randomUUID().toString(); /** * 4.还有一种可能产生的问题,如果我们还没来的及给锁设置过期时间 就崩了 也会造成死锁 * 主要造成的原因是:我们的设置过期时间和加锁 他不是一个原子性的操作 * 在Redis总cli中有这样一个命令:set lock 111 EX 300 NX * 意思就是set<lock,111> EX 过期时间300s 不存在才添加 */ Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", token, 300, TimeUnit.SECONDS); if (lock) { System.out.println("获取分布式锁成功,执行业务中...."); //2.加锁成功 为了防止因为网路问题或者其他的问题 导致我们没有释放锁 造成死锁问题 我们需要设置锁的过期时间 stringRedisTemplate.expire("lock", 30, TimeUnit.SECONDS); Map<String, List<Catalog2Vo>> fromDB = null; try { fromDB = getFromDB(); } finally { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Long result = stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), token); } //2.1 数据返回成功的我们还要解锁 //stringRedisTemplate.delete("lock"); //我们需要根据获取当前锁的线程 获取到它的value 来和一开始uuid来匹配 如果相等 说明在释放自己的锁 //String value = stringRedisTemplate.opsForValue().get("lock"); /** * 6.如果在比较的前面(也就是去查询redis redis给我门返回数据的途中 我们redis中的数据过期了 那别人就有机会进来 重新占了个锁) * 此时别人也叫lock,但是value,是不一样的值 那我们此时 在走我们判断的业务逻辑 进不去 就又造成了 死锁 * 造成的主要原因是:他们不是一个原子性的操作(获取值和和值进行对比) * 解决办法:删除锁必须保证原子性。使用redis+Lua脚本完成 */ //if (value.equals(token)) { //说明在释放自己的锁 那就放心释放 //stringRedisTemplate.delete("lock"); //} //Lua脚本 //String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call" + //"('del', KEYS[1]) else return 0 end"; /** * 删除成功返回 1 不成功0 * Lua脚本解锁保证原子性操作 */ //Integer result = stringRedisTemplate.execute(new DefaultRedisScript<Integer>(script, //Integer.class), //Arrays.asList("lock"), token); return fromDB; } else { //3.加锁失败(等一会儿,再去重试) 类似与自旋 /** * 7.保证加锁【占位+过期时间】和删除锁【判断+删除】的原子性。 更难的事情,锁的自动续期 * 也就是我们的业务还没执行完 我们的锁过期了 那不就bbq了。就好比 我们在网吧 我们正打团 机子给我们提示余额用完了 * 给我们锁机了。 * 所以为了解决这个问题:我们需要解决在业务的执行期间 需要给锁自动续期 * 最简单的方法就是过期时间 给多一点(合理的业务时间)使用try{} finally{} */ System.out.println("获取分布式锁失败,自旋等待中...."); //可以设置休眠100ms在重试 try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } return getCatalogJsonFromDBWithRedisSetnx(); } }
相关链接:https://zhuanlan.zhihu.com/p/42056183
基于 redis 的 setnx()、expire() 方法做分布式锁
setnx()
setnx 的含义就是
SET if Not Exists
,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。expire()
expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。
使用步骤
1、setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
3、执行完业务代码后,可以通过 delete 命令删除 key。
这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用 redis 的 setnx()、get() 和 getset() 方法来实现分布式锁。
基于 redis 的 setnx()、get()、getset()方法做分布式锁
这个方案的背景主要是在 setnx() 和 expire() 的方案上针对可能存在的死锁问题,做了一些优化。
getset()
这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么多次执行这个命令,会出现下边的效果:
- getset(key, “value1”) 返回 null 此时 key 的值会被设置为 value1
- getset(key, “value2”) 返回 value1 此时 key 的值会被设置为 value2
- 依次类推!
使用步骤
- setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
- get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
- 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
- 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
- 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。
基于 Redlock 做分布式锁
官方文档:Redis SET 命令。 Distributed Locks with Redis | Redis
Distributed Locks with Redis
A Distributed Lock Pattern with Redis
一种带有 Redis 的分布式锁模式
Distributed locks are a very useful primitive in many environments where different processes must operate with shared resources in a mutually exclusive way.
在许多环境中,分布式锁是非常有用的原语,在这些环境中,不同的进程必须以互斥的方式使用共享资源进行操作。
There are a number of libraries and blog posts describing how to implement a DLM (Distributed Lock Manager) with Redis, but every library uses a different approach, and many use a simple approach with lower guarantees compared to what can be achieved with slightly more complex designs.
有许多库和博客文章描述了如何使用 Redis 实现 DLM (缓存同步/目录) ,但是每个库使用不同的方法,并且许多使用一种简单的方法,与稍微复杂的设计相比,可以实现较低的保证。
This page describes a more canonical algorithm to implement distributed locks with Redis. We propose an algorithm, called Redlock, which implements a DLM which we believe to be safer than the vanilla single instance approach. We hope that the community will analyze it, provide feedback, and use it as a starting point for the implementations or more complex or alternative designs.
本页描述了使用 Redis 实现分布式锁的更规范的算法。我们提出了一个名为 Redlock 的算法,它实现了一个 DLM,我们相信它比普通的单实例方法更安全。我们希望社区能够分析它,提供反馈,并将其作为实现或更复杂或可选设计的起点。
分布式锁所需的保证
Safety property: Mutual exclusion. At any given moment, only one client can hold a lock.
安全特性: 互斥锁。在任何时刻,只有一个客户可以持有锁
Liveness property A: Deadlock free. Eventually it is always possible to acquire a lock, even if the client that locked a resource crashes or gets partitioned. 活性属性 a: 无死锁。最终,即使锁定资源的客户机崩溃或分区,也总是有可能获得锁
Liveness property B: Fault tolerance. As long as the majority of Redis nodes are up, clients are able to acquire and release locks. 活性属性 b: 容错性。只要大多数 Redis 节点处于启动状态,客户端就能够获取和释放锁
Redisson
文档:https://github.com/redisson/redisson/wiki/Table-of-Content
项目介绍中文文档:https://github.com/redisson/redisson/wiki/Redisson%E9%A1%B9%E7%9B%AE%E4%BB%8B%E7%BB%8D
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
Redisson采用了基于NIO的Netty框架,不仅能作为Redis底层驱动客户端,具备提供对Redis各种组态形式的连接功能,对Redis命令能以同步发送、异步形式发送、异步流形式发送或管道形式发送的功能,LUA脚本执行处理,以及处理返回结果的功能,还在此基础上融入了更高级的应用方案,不但将原生的Redis
Hash
,List
,Set
,String
,Geo
,HyperLogLog
等数据结构封装为Java里大家最熟悉的映射(Map)
,列表(List)
,集(Set)
,通用对象桶(Object Bucket)
,地理空间对象桶(Geospatial Bucket)
,基数估计算法(HyperLogLog)
等结构,在这基础上还提供了分布式的多值映射(Multimap)
,本地缓存映射(LocalCachedMap)
,有序集(SortedSet)
,计分排序集(ScoredSortedSet)
,字典排序集(LexSortedSet)
,列队(Queue)
,阻塞队列(Blocking Queue)
,有界阻塞列队(Bounded Blocking Queue)
,双端队列(Deque)
,阻塞双端列队(Blocking Deque)
,阻塞公平列队(Blocking Fair Queue)
,延迟列队(Delayed Queue)
,布隆过滤器(Bloom Filter)
,原子整长形(AtomicLong)
,原子双精度浮点数(AtomicDouble)
,BitSet
等Redis原本没有的分布式数据结构。不仅如此,Redisson还实现了Redis文档中提到像分布式锁Lock
这样的更高阶应用场景。事实上Redisson并没有不止步于此,在分布式锁的基础上还提供了联锁(MultiLock)
,读写锁(ReadWriteLock)
,公平锁(Fair Lock)
,红锁(RedLock)
,信号量(Semaphore)
,可过期性信号量(PermitExpirableSemaphore)
和闭锁(CountDownLatch)
这些实际当中对多线程高并发应用至关重要的基本部件。正是通过实现基于Redis的高阶应用方案,使Redisson成为构建分布式系统的重要工具。在提供这些工具的过程当中,Redisson广泛的使用了承载于Redis订阅发布功能之上的分布式
话题(Topic)
功能。使得即便是在复杂的分布式环境下,Redisson的各个实例仍然具有能够保持相互沟通的能力。在以这为前提下,结合了自身独有的功能完善的分布式工具,Redisson进而提供了像分布式远程服务(Remote Service)
,分布式执行服务(Executor Service)
和分布式调度任务服务(Scheduler Service)
这样适用于不同场景的分布式服务。使得Redisson成为了一个基于Redis的Java中间件(Middleware)。
Redisson Node
的出现作为驻内存数据网格的重要特性之一,使Redisson能够独立作为一个任务处理节点,以系统服务的方式运行并自动加入Redisson集群,具备集群节点弹性增减的能力。然而在真正意义上让Redisson发展成为一个完整的驻内存数据网格的,还是具有将基本上任何复杂、多维结构的对象都能变为分布式对象的分布式实时对象服务(Live Object Service)
,以及与之相结合的,在分布式环境中支持跨节点对象引用(Distributed Object Reference)的功能。这些特色功能使Redisson具备了在分布式环境中,为Java程序提供了堆外空间(Off-Heap Memory)储存对象的能力。Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。如果您现在正在使用其他的Redis的Java客户端,希望Redis命令和Redisson对象匹配列表 能够帮助您轻松的将现有代码迁徙到Redisson里来。如果目前Redis的应用场景还仅限于作为缓存使用,您也可以将Redisson轻松的整合到像Spring和Hibernate这样的常用框架里。除此外您也可以间接的通过Java缓存标准规范JCache API (JSR-107)接口来使用Redisson。
Redisson生而具有的高性能,分布式特性和丰富的结构等特点恰巧与Tomcat这类服务程序对会话管理器(Session Manager)的要求相吻合。利用这样的特点,Redisson专门为Tomcat提供了会话管理器(Tomcat Session Manager)。
在此不难看出,Redisson同其他Redis Java客户端有着很大的区别,相比之下其他客户端提供的功能还仅仅停留在作为数据库驱动层面上,比如仅针对Redis提供连接方式,发送命令和处理返回结果等。像上面这些高层次的应用则只能依靠使用者自行实现。
Redisson支持Redis 2.8以上版本,支持Java1.6+以上版本。
Redisson的初次使用
- 导入依赖
<!--redisson--> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.11.1</version> </dependency>
- 与第三方框架整合
官方文档:https://github.com/redisson/redisson/wiki/14.-%E7%AC%AC%E4%B8%89%E6%96%B9%E6%A1%86%E6%9E%B6%E6%95%B4%E5%90%88
@Configuration public class MyRedissonConfig { /** * 所有对Redisson的使用都是通过RedissonClient对象来操作 * * @return org.redisson.api.RedissonClient * @author wanglufei * @date 2022/5/18 8:20 PM */ @Bean public RedissonClient redisson() { //1.创建配置对象 Config config = new Config(); //单集群模式 config.useSingleServer().setAddress("redis://192.168.2.115:6379"); //2.根据配置对象创建出RedissonClient实例对象 RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
- 测试
@SpringBootTest public class RedissonClientTest { @Autowired RedissonClient redissonClient; @Test public void test01() { System.out.println(redissonClient); } }
接下来,测试主要是针对分布式锁来做简单的测试,如需还有其他关于Redisson的理解,可以转移到官方文档。
地址:
可重入锁(Reentrant Lock)
基于Redis的Redisson分布式可重入锁RLock
Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
RLock lock = redisson.getLock("anyLock");
// 最常见的使用方法
lock.lock();
大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}
Redisson同时还为分布式锁提供了异步执行的相关方法:
RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);
RLock
对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException
错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphore
对象.
读写锁(ReadWriteLock)
基于Redis的Redisson分布式可重入读写锁
RReadWriteLock
Java对象实现了java.util.concurrent.locks.ReadWriteLock
接口。其中读锁和写锁都继承了RLock接口。分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock"); // 最常见的使用方法 rwlock.readLock().lock(); // 或 rwlock.writeLock().lock();
大家都知道,如果负责储存这个分布式锁的Redis节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了
leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。// 10秒钟以后自动解锁 // 无需调用unlock方法手动解锁 rwlock.readLock().lock(10, TimeUnit.SECONDS); // 或 rwlock.writeLock().lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁 boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS); // 或 boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS); ... lock.unlock();
信号量(Semaphore)
基于Redis的Redisson的分布式信号量(Semaphore)Java对象
RSemaphore
采用了与java.util.concurrent.Semaphore
相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。RSemaphore semaphore = redisson.getSemaphore("semaphore"); semaphore.acquire(); //或 semaphore.acquireAsync(); semaphore.acquire(23); semaphore.tryAcquire(); //或 semaphore.tryAcquireAsync(); semaphore.tryAcquire(23, TimeUnit.SECONDS); //或 semaphore.tryAcquireAsync(23, TimeUnit.SECONDS); semaphore.release(10); semaphore.release(); //或 semaphore.releaseAsync();
闭锁(CountDownLatch)
基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象
RCountDownLatch
采用了与java.util.concurrent.CountDownLatch
相似的接口和用法。RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch"); latch.trySetCount(1); latch.await(); // 在其他线程或其他JVM里 RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch"); latch.countDown();
缓存一致性协议
/**
* 使用redisson分布式锁
*/
public Map<String, List<Catalog2Vo>> getCatalogJsonFromDBWithRedisson() {
/**
* 怎么使用分布式锁
* 1.占坑
* 需要注意锁的名字。锁的粒度,越细越快
* 锁的粒度:具体缓存的是某个数据
*/
RLock lock = redissonClient.getLock("catalogJson-Lock");
//加锁
lock.lock();
Map<String, List<Catalog2Vo>> fromDB = null;
try {
fromDB = getFromDB();
} finally {
//释放锁
lock.unlock();
}
return fromDB;
}
假如我们有一天三级分类的数据被修改了,那我们从缓存获取到的数据,就和我们真实数据库的数据就产生数据不一致性。所以就牵扯到另外一个问题缓存一致性的问题。
最常用的解决缓存数据一致性的模式,分为两种:
- 双写模式
- 失效模式
我们系统的解决方案:
- 缓存的所有数据都有过期时间,数据过期触发主动更新
- 读写数据的时候,加上分布式的读写锁,我们经常读经常写,会有影响。
优秀博客推荐:
- Canal