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

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

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

什么是分布式锁?

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

为什么要使用分布式锁?

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

如何实现分布式锁?

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

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

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

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

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

Redis实现分布式锁

java 复制代码
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;

    @Autowired
    public 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();
                // 如果线程被中断,返回false
                return 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文件

java 复制代码
-- 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 then
    return 1
else
    -- 如果锁已经存在,则返回0
    return 0
end

unlock.lua文件

java 复制代码
-- unlock.lua
-- 参数1: 锁的key
-- 参数2: 请求ID

local lockKey = KEYS[1]
local requestId = ARGV[1]

-- 检查锁是否存在,并且锁的持有者ID与传入的请求ID匹配,如果匹配则删除锁
if redis.call('get', lockKey) == requestId then
    return redis.call('del', lockKey)
else
    -- 如果锁存在但请求ID不匹配,或者锁不存在,则返回0
    return 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方法释放锁。

    java 复制代码
    @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的版本兼容。

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

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

使用场景案例

场景一:数据库记录更新

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

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

场景二:分布式任务调度

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

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

场景三:分布式缓存更新

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

java 复制代码
if (redisDistributedLock.tryLock("cache_item_123", 5000)) {
    try {
        // 更新缓存项
    } finally {
        redisDistributedLock.unlock("cache_item_123");
    }
}
相关推荐
weixin_704266056 小时前
SpringBoot全注解开发指南
java·spring boot·mybatis
Rsun045516 小时前
Redis中实现访问量计数
数据库·redis·缓存
一线大码8 小时前
Java 使用国密算法实现数据加密传输
java·spring boot·后端
摇滚侠9 小时前
限流的方法,Redis 计算器限流算法、滑动时间窗口限流算法、漏漏桶限流算法、令牌桶限流算法,Java 开发
java·数据库·redis
fy1216310 小时前
Redis 下载与安装 教程 windows版
数据库·windows·redis
黑棠会长10 小时前
ABP框架09.数据安全与合规:审计日志与实体变更追踪
分布式·安全·架构·c#·abp
珠海西格12 小时前
四可装置如何监测组件衰减与逆变器效率?
大数据·运维·服务器·分布式·能源
gaozhiyong081312 小时前
深度技术拆解:豆包2 Pro vs Gemini 3—国产工程派与海外原生派的巅峰对决
前端·spring boot·mysql
Flittly13 小时前
【SpringAIAlibaba新手村系列】(3)ChatModel 与 ChatClient 的深度对比
java·人工智能·spring boot·spring