【Redis(9)】Spring Boot整合Redis,实现分布式锁,保证分布式系统中节点操作一致性

news/2025/2/14 5:01:57/

在上一篇系列文章中,咱们利用Redis解决了缓存穿透、缓存击穿、缓存雪崩等缓存问题,Redis除了解决缓存问题,还能干什么呢?这是今天咱们要接着探讨的问题。

分布式系统中,为了保证在多个节点间操作的一致性,引入了分布式锁的概念。那么什么是分布式锁?为什么要用分布式锁?怎么实现分布式锁?带着这些问题,接下来本文将带你一起实现一个基于Redis的分布式锁?

什么是分布式锁?

分布式锁是一种在分布式系统中用来保证同一时间只有一个进程能操作共享资源的机制。它类似于我们熟知的单机环境下的锁,但分布式锁跨越了单机的界限,作用于多台机器之间,确保了在多个节点上的协调一致。

为什么要使用分布式锁?

在没有分布式锁的情况下,多个节点可能会同时修改共享资源,导致数据不一致甚至丢失。例如,在电子商务平台的库存管理中,如果多个用户同时下单购买同一件商品,而系统没有正确地管理库存,就可能出现超卖的情况。

如何实现分布式锁?

实现分布式锁有多种方式,以下是一些常见的实现策略:

基于数据库的锁:使用数据库的排他锁(如SQL中的SELECT ... FOR UPDATE)可以实现简单的分布式锁。

基于缓存的锁:使用分布式缓存系统(如Redis)提供的原子命令(如SETNX)来实现锁的功能。

基于ZooKeeper的锁:ZooKeeper的临时有序节点可以用来实现分布式锁,通过节点的创建和监听来实现锁的获取和释放。

基于etcd的锁:etcd是一个分布式键值存储,也常被用来实现分布式锁,它提供了可靠的键值存储和原子操作。

Redis实现分布式

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;/*** 分布式锁实现,使用Redis作为后端存储。*/
@Component
public class RedisDistributedLock {// 从配置文件中读取锁的默认有效期(毫秒)@Value("${lock.leaseTime:30000}")private long leaseTime;private final StringRedisTemplate stringRedisTemplate;private final RedisScript lockScript;private final RedisScript unlockScript;private final ReentrantLock lockReentrantLock = new ReentrantLock();// 存储锁的持有者信息private final Map<String, String> locks = new ConcurrentHashMap<>();// 存储锁的过期时间private final Map<String, Long> lockExpirationTimes = new ConcurrentHashMap<>();// 锁重试间隔时间private static final long LOCK_RETRY_INTERVAL_MS = 100L;// 锁最大重试次数private static final long LOCK_MAX_RETRY_TIMES = 10L;@Autowiredpublic RedisDistributedLock(StringRedisTemplate stringRedisTemplate, ResourceLoader resourceLoader) throws IOException {this.stringRedisTemplate = stringRedisTemplate;// 加载和编译Lua锁脚本this.lockScript = loadScript(resourceLoader, "lock.lua");this.unlockScript = loadScript(resourceLoader, "unlock.lua");}/*** 从指定路径加载Lua脚本。*/private RedisScript loadScript(ResourceLoader resourceLoader, String scriptPath) throws IOException {Resource resource = resourceLoader.getResource("classpath:" + scriptPath);String script = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);return stringRedisTemplate.getConnectionFactory().getConnection().scriptLoad(script);}/*** 尝试获取锁。** @param lockKey 锁的键。* @param waitTime 锁的最长等待时间。* @return 如果获取到锁,返回true;否则返回false。*/public boolean tryLock(String lockKey, long waitTime) {String requestId = UUID.randomUUID().toString();long endTime = System.currentTimeMillis() + waitTime;int attempts = 0;while (System.currentTimeMillis() < endTime && attempts < LOCK_MAX_RETRY_TIMES) {if (tryLockInner(lockKey, requestId)) {lockReentrantLock.lock();try {// 记录锁信息和过期时间locks.put(lockKey, requestId);lockExpirationTimes.put(lockKey, System.currentTimeMillis() + leaseTime);return true;} finally {lockReentrantLock.unlock();}}attempts++;try {Thread.sleep(LOCK_RETRY_INTERVAL_MS);} catch (InterruptedException e) {Thread.currentThread().interrupt();// 如果线程被中断,返回falsereturn false;}}return false;}/*** 尝试获取锁的内部方法,使用Redis Lua脚本以保证原子性。*/private boolean tryLockInner(String lockKey, String requestId) {return (Boolean) stringRedisTemplate.execute(lockScript,Collections.singletonList(lockKey),requestId,TimeUnit.MILLISECONDS.toMillis(leaseTime));}/*** 释放锁。** @param lockKey 锁的键。*/public void unlock(String lockKey) {lockReentrantLock.lock();try {String requestId = locks.remove(lockKey);if (requestId != null) {unlockInner(lockKey, requestId);lockExpirationTimes.remove(lockKey);}} finally {lockReentrantLock.unlock();}}/*** 释放锁的内部方法,使用Redis Lua脚本以保证原子性。*/private boolean unlockInner(String lockKey, String requestId) {return (Long) stringRedisTemplate.execute(unlockScript,Collections.singletonList(lockKey),requestId) == 1;}/*** 定时任务,用于续期已获取的锁。*/@Scheduled(fixedRateString = "${lock.renewRate:1000}")public void renewLocks() {lockReentrantLock.lock();try {long now = System.currentTimeMillis();for (Map.Entry<String, Long> entry : lockExpirationTimes.entrySet()) {if (now > entry.getValue()) {// 如果锁已过期,释放锁unlock(entry.getKey());} else {// 续期锁boolean renewed = stringRedisTemplate.expire(entry.getKey(), leaseTime - (now - entry.getValue()), TimeUnit.MILLISECONDS);if (!renewed) {// 如果续期失败,释放锁unlock(entry.getKey());}}}} catch (Exception e) {// 记录异常日志} finally {lockReentrantLock.unlock();}}
}

请注意以下几点:

  1. 为了加载Lua脚本,这里使用了Spring的ResourceLoader。需要在类路径下提供lock.luaunlock.lua文件。

  2. tryLock方法只接受锁的键和等待时间,leaseTime从配置文件中获取。

  3. unlock方法只负责解锁,不从等待队列中移除。

  4. renewLocks方法会检查每个锁是否过期,并相应地续期或释放。

  5. 使用了ConcurrentHashMap来存储锁信息和锁过期时间,以支持高并发。

  6. 添加了异常处理和中断处理,但没有实现日志记录。需要根据日志框架(如SLF4J、Log4J等)添加适当的日志记录。

  7. 请确保的配置文件(如application.properties)中设置了lock.leaseTimelock.renewRate属性。

lock.lua文件

-- lock.lua
-- 参数1: 锁的key
-- 参数2: 请求ID
-- 参数3: 锁的超时时间(毫秒)local lockKey = KEYS[1]
local requestId = ARGV[1]
local leaseTime = tonumber(ARGV[2])-- 检查锁是否存在,如果不存在则设置锁,并返回1
if redis.call('set', lockKey, requestId, 'NX', 'PX', leaseTime) == 1 thenreturn 1
else-- 如果锁已经存在,则返回0return 0
end

unlock.lua文件

-- unlock.lua
-- 参数1: 锁的key
-- 参数2: 请求IDlocal lockKey = KEYS[1]
local requestId = ARGV[1]-- 检查锁是否存在,并且锁的持有者ID与传入的请求ID匹配,如果匹配则删除锁
if redis.call('get', lockKey) == requestId thenreturn redis.call('del', lockKey)
else-- 如果锁存在但请求ID不匹配,或者锁不存在,则返回0return 0
end

这些Lua脚本通过使用Redis的原子命令来确保锁的获取和释放操作的原子性。在lock.lua脚本中,使用set命令尝试设置一个锁,如果锁不存在(NX),则设置成功并返回1,否则返回0。

unlock.lua脚本中,首先检查锁是否存在,并且当前请求者是否是锁的持有者,如果是,则删除锁。

请将这些脚本保存为.lua文件,并确保它们位于Spring Boot项目的classpath路径下,以便RedisDistributedLock类可以加载它们。同时,确保Redis服务器配置允许执行Lua脚本,并且没有禁用Lua脚本命令。

使用方式

首先,确保您的项目中已经包含了Spring框架和Spring Data Redis的相关依赖。然后,按照以下步骤使用上述代码:

  1. 配置Redis:在Spring配置文件中配置Redis连接信息。

  2. 注入依赖:在Spring组件中注入StringRedisTemplateResourceLoader

  3. 配置锁参数:在配置文件中设置锁的有效期(lock.leaseTime)和锁续期的时间间隔(lock.renewRate)。

  4. 使用锁:在需要同步的代码块前后,使用tryLock方法尝试获取锁,并在操作完成后调用unlock方法释放锁。

    @Autowired
    private RedisDistributedLock redisDistributedLock;public void criticalSection() {String lockKey = "some_resource_key";long waitTime = 10000; // 等待10秒获取锁if (redisDistributedLock.tryLock(lockKey, waitTime)) {try {// 临界区代码} finally {redisDistributedLock.unlock(lockKey);}}
    }

优缺点

优点

  • 线程安全:通过ReentrantLock确保了多线程环境下的线程安全。
  • 自动续期:通过定时任务自动续期,减少了锁提前释放的风险。
  • 高可用:Redis的分布式特性提供了高可用的锁机制。

缺点

  • 资源消耗:定时任务和锁重试机制可能会增加系统资源的消耗。
  • 复杂性:引入了额外的Lua脚本和锁管理逻辑,增加了系统的复杂性。

改进点

异常处理:增强异常处理,确保在出现异常时能够记录日志并采取适当的恢复措施。

性能监控:引入性能监控,以便及时发现并解决潜在的性能瓶颈。

锁优化:考虑使用更高效的锁重试策略,如指数退避,以减少资源消耗。

注意事项

版本兼容性:确保Redis和Spring Data Redis的版本兼容。

锁超时设置:合理设置锁的有效期,避免死锁或资源浪费。

资源释放:确保在操作完成后释放锁,避免资源长时间被占用。

使用场景案例

场景一:数据库记录更新

在多实例的微服务架构中,当需要更新共享数据库中的记录时,可以使用分布式锁来保证同一时间只有一个实例进行更新。

if (redisDistributedLock.tryLock("db_record_123", 5000)) {try {// 更新数据库记录} finally {redisDistributedLock.unlock("db_record_123");}
}

场景二:分布式任务调度

分布式任务调度系统中,使用分布式锁可以避免同一个任务被多个实例重复执行。

if (redisDistributedLock.tryLock("task_123", 5000)) {try {// 执行任务} finally {redisDistributedLock.unlock("task_123");}
}

场景三:分布式缓存更新

当多个服务实例需要更新同一个缓存项时,使用分布式锁可以保证只有一个实例在任何给定时间更新缓存。

if (redisDistributedLock.tryLock("cache_item_123", 5000)) {try {// 更新缓存项} finally {redisDistributedLock.unlock("cache_item_123");}
}


http://www.ppmy.cn/news/1431037.html

相关文章

jmeter5.4.1源码编译(IDEA)问题解决

问题现象&#xff1a;最近想更深入的研究下jmeter5.4.1的原理及功能具体实现&#xff0c;从官网down了个源码&#xff0c;在本地使用IDEA工具导入项目、编译时&#xff0c;报以下错误&#xff1a; class jdk.internal.loader.ClassLoaders$PlatformClassLoader cannot be cast…

物联网配网工具多元化助力腾飞——智能连接,畅享未来

随着物联网技术的迅猛发展&#xff0c;智能插座、蓝牙网关作为其中常见的智能物联设备&#xff0c;无论是功能还是外观都有很大的改进&#xff0c;在智能化越来越普遍的情况下&#xff0c;它们的应用场景也在不断拓宽。对于智能设备而言&#xff0c;配网方式的选择对于设备的成…

HTML随机点名程序

案例要求 1.点击点名按钮&#xff0c;名字界面随机显示&#xff0c;按钮文字由点名变为停止 2.再次点击点名按钮&#xff0c;显示当前被点名学生姓名&#xff0c;按钮文字由停止变为点名 案例源码 <!DOCTYPE html> <html lang"en"> <head> <m…

在ArcGIS中,矢量数据有.shp,.mdb和.gdb,为啥建议使用gdb?

在ArcGIS中,矢量数据可以存储在多种格式中,如 .shp (Shapefile)、.mdb (Microsoft Access Database) 和 .gdb (Geodatabase)。每种格式都有其特定的用途和优缺点,但通常推荐使用 Geodatabase(.gdb)格式,原因如下: 1. 更高的数据容量和性能 容量: Shapefiles 和 MDB 文…

【26考研】考研备考计划4.22开始

A海海: 408:重中之重&#xff0c;和数学同等地位&#xff01;越早开始越好&#xff01;前期直接跟着王道视频课学习&#xff0c;教材直接用王道四本书&#xff0c;顺序结构的话按照数据结构-计算机组成原理-操作系统-计算机网络的顺序来学习。刚开始学会感觉很吃力很难&#xf…

【极速前进】20240422:预训练RHO-1、合成数据CodecLM、网页到HTML数据集、MLLM消融实验MM1、Branch-Train-Mix

一、RHO-1&#xff1a;不是所有的token都是必须的 论文地址&#xff1a;https://arxiv.org/pdf/2404.07965.pdf 1. 不是所有token均相等&#xff1a;token损失值的训练动态。 ​ 使用来自OpenWebMath的15B token来持续预训练Tinyllama-1B&#xff0c;每1B token保存一个che…

Android Studio实现内容丰富的安卓校园超市

获取源码请点击文章末尾QQ名片联系&#xff0c;源码不免费&#xff0c;尊重创作&#xff0c;尊重劳动 项目代号168 1.开发环境 后端用springboot框架&#xff0c;安卓的用android studio开发 android stuido3.6 jdk1.8 idea mysql tomcat 2.功能介绍 安卓端&#xff1a; 1.注册…

stash拯救犹豫不决的commit

当使用git时&#xff0c;发现同事提交了代码&#xff0c;但是我的代码的还没有commit&#xff0c;我想先拉取他们的代码一起测试&#xff0c;测试成功后再commit&#xff0c;最好的做法是什么? 1. 保存当前更改 将当前的未提交更改暂存到Git堆栈&#xff1a;git stash save …