在上一篇系列文章中,咱们利用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();
}
}
}
请注意以下几点:
为了加载Lua脚本,这里使用了Spring的
ResourceLoader
。需要在类路径下提供lock.lua
和unlock.lua
文件。
tryLock
方法只接受锁的键和等待时间,leaseTime
从配置文件中获取。
unlock
方法只负责解锁,不从等待队列中移除。
renewLocks
方法会检查每个锁是否过期,并相应地续期或释放。使用了
ConcurrentHashMap
来存储锁信息和锁过期时间,以支持高并发。添加了异常处理和中断处理,但没有实现日志记录。需要根据日志框架(如SLF4J、Log4J等)添加适当的日志记录。
请确保的配置文件(如
application.properties
)中设置了lock.leaseTime
和lock.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的相关依赖。然后,按照以下步骤使用上述代码:
配置Redis:在Spring配置文件中配置Redis连接信息。
注入依赖 :在Spring组件中注入
StringRedisTemplate
和ResourceLoader
。配置锁参数 :在配置文件中设置锁的有效期(
lock.leaseTime
)和锁续期的时间间隔(lock.renewRate
)。使用锁 :在需要同步的代码块前后,使用
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");
}
}