缓存分布式锁

背景

在单体应用中,项目直接部署到一台机器上,所有的访问都流向此机器。而为了增强单体应用服务能力,会通过在代码中使用多线程,从而给服务更好的处理能力。在此情况下,如果有大量请求访问,那么对于单体应用中涉及数据的操作(增删改)部分,容易造成线程之间的对于资源的争抢而产生脏数据或数据不一致,此时我们可以通过加锁来解决,JUC包下提供了各种锁,来避免上述情况。

如果我们将此应用部署到多台服务器上(从单体应用变成分布式服务),如图所示:

该商品服务部署到多台服务器上,通过负载均衡方式将流量分发到不同的机器上,由于使用的锁只是本地锁,无法保证在分布式部署的情况下只有一个请求或线程访问数据库和缓存。

再此情景下,引入分布式锁,通过分布式锁,针对某一数据或服务,控制分布式应用在高并发的情况下,仍只有一个服务访问数据库或缓存。

自定义分布式锁

原理

如下图所示:

当有多个应用请求同一个服务时,让多个服务去获取锁(占锁),获取到锁的应用才去执行业务,业务执行完成之后,释放锁。

这里,我们使用redis实现分布式锁。当应用请求时,首先向redis中写入一个key 为lock的键值对(value任意),当其他应用在请求时,首先会检查redis中是否存在这个key值,如果存在,则等待key已经被删除之后在执行。创建该key的应用首先会执行,执行完成之后,删除key,交由其他线程执行。流程图如下:

原始代码:

public Map<String,Object> getCateLogJsonWithRedisLock()  throws Exception  {

		return this.getCategoryFromDb();
	}
	
	public Map<String,Object> getCategoryFromDb() throws Exception {

		Map<String, Object> result = new HashMap<>();

		String key = "cate:log:json";

		// 如果缓存中存在,则直接从缓存中提取数据;
		if (redisTemplate.hasKey(key)) {

			String cateLogJSON = redisTemplate.opsForValue().get(key);

			result = JSON.parseObject(cateLogJSON, new com.alibaba.fastjson.TypeReference<Map<String, Object>>() {
			});

			return result;
		}
		// 如果缓存中不存在数据,则从数据库中查询,并将结果写到缓存中;
		result = getCateLogJson2();

		redisTemplate.opsForValue().set(key, JSON.toJSONString(result), 1, TimeUnit.SECONDS);

		return result;
	}

 自定义分布式锁--- 阶段一

 原理图:

 如上图所示,这里我们使用redis实现分布式锁,redis中string类型操作setnx操作,表示当前redis中不存在指定key时,向redis中写入内容;该方法在代码中体现为:

redisTemplate.opsForValue().setIfAbsent("lock", "1111")

表示为当redis中不存在key为lock的值时,将它写入到缓存中,并且value为 1111;

按照原理图修改代码后:

public Map<String,Object> getCateLogJsonWithRedisLock()  throws Exception  {

		Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1111",10,TimeUnit.SECONDS);

		Map<String, Object> result = new HashMap<>();

		if (!lock) {
			// 加锁不成功,执行
			try{

				Thread.sleep(100);

			}catch (Exception ex) {

				ex.printStackTrace();

			}

            // 表示加锁不成功的情况下,尝试在100ms后重新获取锁;
			result = getCateLogJsonWithRedisLock();

		} else {

		   result =	this.getCategoryFromDb();

		   // 业务执行完成,尝试删除锁;
		   redisTemplate.delete("lock");
		}
		return  result;
	}

思考:

如果我们的业务代码:this.getCategoryFromDb()在执行过程中宕机或出现异常,导致无法删除锁,从而导致锁无法被删除引起死锁的现象;

解决方案:

 在使用方法setIfAbsent 设置值时,可以使用给key设置过期时间;

 代码调整:

Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1111",10,TimeUnit.SECONDS);

自定义分布式锁---阶段二

在阶段一中,我们保证了加锁的一致性,我们通过代码

Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1111",10,TimeUnit.SECONDS);

保证了加锁的一致性,但在删错锁方面,仍存在问题。试想,如果设置锁过期的时间为10s,但业务代码的执行时间需要20s(大于锁过期的时间)那么在业务代码执行完成之后,删除的锁一定是自己当初自己上的那把锁吗?

举个例子,当前请求A执行时,在redis中增加key为lock的键值对作为锁。请求B 、请求C在执行时,检查到已经上锁操作,那么会进入等待并不断获取到锁。如果请求A在执行过程中由于业务时间过长,导致lock自动过期,请求B获取到锁,并开始执行业务逻辑。在业务A执行完成后,删除key,此时删除的lock是B设置的锁,依此类推,每个请求在执行完成后,删除的锁不是自己设置的。依然会导致出现脏数据或者数据不一致;

解决方案如下图所示:

代码调整:

public Map<String,Object> getCateLogJsonWithRedisLock()  throws Exception  {

		String uuid = UUID.randomUUID().toString();

		Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,10,TimeUnit.SECONDS);

		Map<String, Object> result = new HashMap<>();

		if (!lock) {
			// 加锁不成功,执行
			try{

				Thread.sleep(100);

			}catch (Exception ex) {

				ex.printStackTrace();

			}

			result = getCateLogJsonWithRedisLock();

		} else {

		   result =	this.getCategoryFromDb();

		   // 业务执行完成,尝试删除锁;
		   String lockValue = redisTemplate.opsForValue().get("lock");

			if (lockValue.equals(uuid)) {

				redisTemplate.delete("lock");

		   }

		}

		return  result;
	}

 思考:如果我们删除锁的时候,遇到如下情况:

 

lock在redis中保存的时间为10s,当服务A拿到数据时,redis已经将对应的key删除。尽管在执行代码lockValue.equals(uuid)比对成功,执行删除操作,但此前由于网络等问题导致在获取数据比对前,当前服务创建的lock被删除掉,服务B开始抢占锁并创建自己对应的key为lock的键值对。所以,此时服务A删除的锁,是服务B 创建的;

解决方案: 获取值对比 + 对比成功删除 => 这两项操作合在一起必须得是原子操作;

在这里我们使用lua脚本实现锁的原子删除,将需要比对的值放到redis一侧进行比较,当比对成功后直接删除;代码调整如下:

public Map<String,Object> getCateLogJsonWithRedisLock()  throws Exception  {

		String uuid = UUID.randomUUID().toString();

		Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,10,TimeUnit.SECONDS);

		Map<String, Object> result = new HashMap<>();

		if (!lock) {
			// 加锁不成功,执行
			try{

				Thread.sleep(100);

			}catch (Exception ex) {

				ex.printStackTrace();

			}

			result = getCateLogJsonWithRedisLock();

		} else {

		   result =	this.getCategoryFromDb();

		   // 业务执行完成,尝试删除锁;
			String script = "if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n" +
					"\n" +
					"return redis.call(\"del\", KEYS[1])\n" +
					"\n" +
					"else\n" +
					"\n" +
					"return 0\n" +
					"\n" +
					"end"; // 删除锁的lua脚本;

			Integer redisExecuteResult = redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList(new String[]{"lock"}), uuid);
			
		}

		return  result;
	}

redisTemplate.execute 负责执行lua脚本:

    第一个参数是需要执行的脚本对象;

    第二个参数是需要操作的key的集合;

    第三个参数是需要在redis一端比对值;

new DefaultRedisScript<Integer>(script, Integer.class) 传递参数:

    script 表示当前需要执行的lua脚本文件;

    Integer.class 表示当前需要执行的脚本文件后返回的值;

思考: 如果我的业务执行时间很长,导致删除lock时,lock已经不存在,那么删除操作依然失败。
解决方案:

  1. 最简单的方式是增加lock的存活时间;

     2. 最复杂的方法是增加看门狗机制,在业务执行完成以前,不断给lock续期;

总结

综上,使用redis作为分布式锁,必须满足以下两点:

  1. 加锁保证原子性;
  2. 解锁或删除锁必须保证原子性;
### Redis 集群与分布式缓存锁相关面试题 以下是关于 Redis 集群和分布式缓存锁的常见面试问题,涵盖理论知识、实际应用以及潜在问题解决方法。 --- #### 1. Redis 集群的工作原理是什么? Redis 集群采用无中心结构,每个节点保存数据和整个集群状态,每个节点都与其他所有节点连接。集群预分好 16384 个槽位,当需要在 Redis 集群中放置一个 key-value 对时,根据 key 的哈希算法与槽位取模,然后确定放置在哪个槽位[^2]。 --- #### 2. Redis 集群在网络分区时如何保证可用性? 根据 CAP 理论,Redis 集群更倾向于 AP(Available and Partition-tolerant)模型。这意味着在网络分割的情况下,Redis 集群始终能够响应请求(尽管可能不保证最终一致性)。分区容忍性确保 Redis 在节点之间的网络分区时仍能保持运行并处理数据的读写请求[^1]。 --- #### 3. Redis 分布式锁的基本实现方式有哪些? Redis 分布式锁可以通过 `SETNX` 命令实现。该命令尝试设置一个键值对,如果键不存在则成功返回 1,否则返回 0。业务代码执行完成后通过 `DEL` 命令释放锁。此外,Redisson 是一种基于 Redis 实现的 Java 驻内存数据网格,提供了多种分布式锁的实现,包括可重入锁[^4]。 --- #### 4. 分布式锁可能存在哪些问题?如何解决? 分布式锁可能存在的问题及解决方案如下: - **超时问题**:业务代码执行时间超过锁时间可能导致锁提前释放。解决方案是使用 WatchDog 机制动态延长锁的有效期。 - **主从一致性问题**:主机将数据异步同步给从机时,若主机宕机可能导致死锁。解决方案是使用 Redisson 等工具,提供更可靠的锁实现。 - **不可重入问题**:获得锁的线程无法再次进入相同的锁代码块。解决方案是使用可重入锁,如 Redisson 提供的实现[^4]。 --- #### 5. 如何避免缓存穿透、击穿和雪崩? - **缓存穿透**:查询的数据在数据库和缓存中均不存在,导致大量请求直接访问数据库。解决方案包括在缓存中存储空对象或使用布隆过滤器[^3]。 - **缓存击穿**:热点数据过期瞬间,大量请求同时访问数据库。解决方案是为热点数据设置永不过期或加互斥锁。 - **缓存雪崩**:大量数据在同一时刻过期,导致数据库压力过大。解决方案包括上线前预热数据、分散过期时间和使用集群[^3]。 --- #### 6. Redis 持久化的方式有哪些?各自的特点是什么? Redis 持久化有以下两种方式: - **RDB(Redis Database Backup)**:快照方式,在指定的时间间隔内将内存中的数据集快照写入磁盘。特点是性能高,但可能会丢失最后一次快照后的数据。 - **AOF(Append Only File)**:记录服务器执行的所有写操作命令,重启时重新执行这些命令恢复数据。特点是数据完整性更高,但文件较大且恢复速度较慢。 --- #### 7. Redis 支持的数据类型有哪些?它们的应用场景是什么? Redis 支持以下数据类型及其应用场景: - **String**:用于存储简单的键值对,支持递增递减操作。 - **Hash**:适用于存储对象属性,如用户信息。 - **List**:适用于消息队列、最新 N 条数据等场景。 - **Set**:适用于去重、好友推荐等场景。 - **ZSet(Sorted Set)**:适用于排行榜、优先级队列等场景。 --- #### 8. Redis 单线程模型为什么性能如此高效? Redis 的单线程模型基于事件驱动和 I/O 多路复用技术,充分利用了内存操作的优势。CPU 不是 Redis 的瓶颈,瓶颈最有可能是机器内存大小和网络带宽。因此,单线程模型不仅易于实现,还能避免多线程带来的上下文切换开销。 --- #### 9. Redis 集群模式下如何进行数据分片? Redis 集群模式下通过哈希槽机制进行数据分片。集群预分配 16384 个槽位,每个 key 根据哈希算法映射到某个槽位,槽位再分配到具体的节点上。官方推荐使用三主三从的架构以提高容错能力。 --- #### 10. Redis 分布式锁的优化方案有哪些? Redis 分布式锁的优化方案包括: - 使用 Redisson 提供的高级锁功能,如可重入锁、公平锁等。 - 引入 WatchDog 机制动态延长锁的有效期,避免因业务执行时间过长而导致锁提前释放。 - 设置合理的锁超时时间,防止死锁发生[^4]。 --- ```python # 示例代码:使用 Redis 实现分布式锁 import redis client = redis.StrictRedis(host='localhost', port=6379, db=0) def acquire_lock(lock_name, acquire_timeout=10): identifier = str(uuid.uuid4()) end = time.time() + acquire_timeout while time.time() < end: if client.setnx(lock_name, identifier): return identifier time.sleep(0.001) return False def release_lock(lock_name, identifier): with client.pipeline() as pipe: while True: try: pipe.watch(lock_name) if pipe.get(lock_name) == identifier: pipe.multi() pipe.delete(lock_name) pipe.execute() return True pipe.unwatch() break except redis.WatchError: pass return False ``` --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

VogtZhao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值