目录
前言
分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现。如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往通过互斥来防止彼此之间的干扰。
实现分布式锁的方式有很多,可以通过各种中间件来进行分布式锁的设计,包括Redis、Zookeeper等。如下图所示:
1、实现锁的方法
如下图所示锁的流程:
1.1. setnx命令
属于最简单的锁,不推荐生产使用。
SETNX toilet_1 "occupied" # 尝试锁门
如果返回1:成功
如果返回0:失败
问题:如果客户端崩,永远被占着(死锁)。
1.2. 带过期时间的锁
属于对setnx命令的改进版:
SETNX toilet_1 "occupied"
EXPIRE toilet_1 30 # 30秒后自动解锁
仍然有问题:两条命令不是原子的,可能在SETNX和EXPIRE之间崩溃。
1.3. 原子命令(推荐)
该命令可使用于生产级方案:
SET toilet_1 "user_123" NX EX 30 # 原子操作:锁门+设置30秒自动开锁
1.4. RedLock算法详解
当需要更高可靠性时,Redis作者Antirez提出的分布式锁算法:
1.实现原理
获取当前毫秒级时间戳T1
依次向N个独立的Redis实例申请锁
计算获取锁总耗时 = 当前时间T2 - T1
必须小于锁有效时间
必须获得多数(N/2+1)节点认可
锁实际有效时间 = 初始设置时间 - 获取锁总耗时。
代码示例:
// RedissonRedLock.tryLock()的核心逻辑
while (waitTimeRemaining > 0) {
long start = System.nanoTime();
// 尝试从多数节点获取锁
int acquiredLocks = tryAcquireMultipleLocks();
if (acquiredLocks >= majority) {
// 计算实际有效时间
long elapsed = System.nanoTime() - start;
long lockTime = leaseTime - TimeUnit.NANOSECONDS.toMillis(elapsed);
if (lockTime > 0) {
// 对所有成功节点设置统一过期时间
scheduleLockExpiration(lockTime);
return true;
}
// 超时则释放已获得的锁
releaseAcquiredLocks();
}
// 等待随机时间后重试
waitTimeRemaining -= calculateWaitTime();
}
2.设计目的
当单个Redis节点宕机时,系统仍能正常工作
防止主从切换时的锁失效(原主节点崩溃,从节点晋升但未同步锁信息)
3.关键保障机制
时钟同步:所有Redis节点必须时间同步(NTP)
过期时间补偿:扣除锁获取耗时
多数派原则:至少(N/2 + 1)个节点确认
4.局限性
1. 仍然存在的竞争问题
2. 网络分区问题
当发生网络分区时:
客户端可能无法感知部分节点状态
可能出现多个客户端同时认为自己持有锁
3. 性能开销
获取多个锁的延迟显著高于单节点:
通常比单节点慢3-5倍
不适合高频短时锁场景
而对于RedLock的本质作用确实主要是为了解决单点故障问题,而不是提升并发性能,并未彻底解决一致性,如果要解决一致性问题,需要结合防护令牌或分布式事务。
1.5. 防护令牌(Fencing Token)模式
当发生锁竞争的时候,假设5节点RedLock:
客户端A获得节点1、2、3的锁
客户端B获得节点3、4、5的锁
此时:两个客户端都认为自己获得了锁(都获得3个节点)
实际发生了冲突(节点3被双方认为属于自己)
代码示例:
// 获取锁时返回单调递增的token
LockResult result = redLock.tryLockWithToken();
long token = result.getToken();
// 操作资源时验证token
if (resource.getCurrentToken() < token) {
resource.write(data, token);
} else {
throw new ConcurrentModificationException();
}
实际实现中会加入** fencing token(防护令牌)机制
每次锁获取附带单调递增的token
资源操作时需要验证token顺序。
1.6. 看门狗机制
在上述的章节了解到,防护令牌可以解决锁竞争一致性的问题,那么如果在锁执行过程中,过期时间到期,而业务还没执行完,那么该怎么办呢?
看门狗(Watchdog)机制是Redis分布式锁中确保业务执行期间锁不会意外释放的关键设计,尤其在Redisson等客户端中广泛使用。
当业务执行时间超过锁的初始过期时间时,防止其他客户端提前获取锁导致数据竞争。
流程:
// 获取锁(默认30秒看门狗时间)
RLock lock = redisson.getLock("order_lock");
lock.lock(); // 内部启动看门狗线程
try {
// 执行业务逻辑(可能超过30秒)
processOrder();
} finally {
lock.unlock(); // 释放时会停止看门狗
}
锁获取时:
public void lock() {
try {
lockInterruptibly();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public void lockInterruptibly() throws InterruptedException {
// 尝试获取锁,默认leaseTime=30秒
tryAcquireAsync(leaseTime, TimeUnit.MILLISECONDS).sync();
// 启动看门狗线程
scheduleExpirationRenewal();
}
看门狗线程:
protected void scheduleExpirationRenewal() {
Thread renewalThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 每10秒(leaseTime/3)续期一次
try {
Thread.sleep(leaseTime / 3);
// 通过Lua脚本续期
String script =
"if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
"return redis.call('pexpire', KEYS[1], ARGV[1]); " +
"else return 0; end";
redis.eval(script,
Collections.singletonList(getName()),
internalLockLeaseTime, getLockName(Thread.currentThread().getId()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
renewalThread.start();
}
参数和配置方式如下:
2、使用场景
用一个电影院抢座的例子,通过Java代码展示Redis分布式锁的实际应用。这个场景非常贴近生活,容易理解分布式锁的必要性。
假设有一个热门电影场次,多个用户同时在线选座,我们需要保证:
一个座位只能被一个用户选中
用户有10分钟支付时间
超时未支付自动释放座位
1、基础配置
首先添加Redis和Redisson(Redis Java客户端)依赖:
<!-- pom.xml -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
初始化Redis连接:
public class RedisLockDemo {
private static RedissonClient redisson;
static {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
redisson = Redisson.create(config);
}
}
二、简单实现:选座锁
1. 获取座位锁
public boolean lockSeat(String seatNumber, String userId) {
// 获取分布式锁对象
RLock lock = redisson.getLock("seat:" + seatNumber);
try {
// 尝试加锁,waitTime=0表示不等待,leaseTime=10分钟自动解锁
return lock.tryLock(0, 10, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
2. 释放座位锁
public void unlockSeat(String seatNumber, String userId) {
RLock lock = redisson.getLock("seat:" + seatNumber);
// 检查是否还被当前线程持有
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
3. 完整选座流程
public boolean selectSeat(String seatNumber, String userId) {
if (!lockSeat(seatNumber, userId)) {
System.out.println(userId + " 抢座失败,座位已被锁定");
return false;
}
try {
System.out.println(userId + " 成功锁定座位 " + seatNumber);
// 模拟用户支付流程
boolean paid = mockPaymentProcess(userId);
if (paid) {
System.out.println(userId + " 支付成功,座位确认");
return true;
} else {
System.out.println(userId + " 支付超时,座位释放");
return false;
}
} finally {
unlockSeat(seatNumber, userId);
}
}
private boolean mockPaymentProcess(String userId) {
// 模拟50%概率支付成功
try {
Thread.sleep(2000); // 模拟支付思考时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Random().nextBoolean();
}
3、高级特性:锁续期
当用户支付时间可能超过10分钟时,需要自动续期:
public boolean lockSeatWithRenewal(String seatNumber, String userId) {
RLock lock = redisson.getLock("seat:" + seatNumber);
try {
// 获取锁,并设置看门狗自动续期(默认30秒)
lock.lock();
// 启动一个线程定期续期
new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(5000); // 每5秒续期一次
lock.expire(10, TimeUnit.MINUTES); // 续期10分钟
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
return true;
} catch (Exception e) {
return false;
}
}
4、测试用例
public static void main(String[] args) {
RedisLockDemo demo = new RedisLockDemo();
// 模拟3个用户同时抢5号座位
new Thread(() -> demo.selectSeat("A05", "用户1")).start();
new Thread(() -> demo.selectSeat("A05", "用户2")).start();
new Thread(() -> demo.selectSeat("A05", "用户3")).start();
}
输出可能结果:
用户1 成功锁定座位 A05
用户2 抢座失败,座位已被锁定
用户3 抢座失败,座位已被锁定
用户1 支付成功,座位确认
5、关键点解析
锁的Key设计:
seat:A05
明确表示对A05座位的锁唯一标识:虽然没有直接使用userId作为value,但Redisson内部会维护线程与锁的关系
自动释放:即使程序崩溃,10分钟后锁也会自动释放
可重入性:同一个线程可以多次获取同一把锁(Redisson特性)
6、对比生活场景
技术概念 | 电影院例子 |
---|---|
Redis服务器 | 电影院售票系统 |
分布式锁 | 座位锁定状态 |
锁的Key | 座位号(如A05) |
锁的Value | 售票员记录的本子(谁锁的) |
锁过期时间 | "保留座位10分钟"的告示牌 |
获取锁失败 | 看到座位已经被标记"已预订" |
锁续期 | 顾客请求延长保留时间 |
这个例子展示了:
如何用Redis解决现实中的资源竞争问题
Java中实际使用Redis分布式锁的代码写法
处理锁超时、续期等常见场景的方法
通过电影院选座这种熟悉的场景,应该能更直观地理解Redis分布式锁的工作机制了。实际开发中,使用Redisson等成熟客户端可以避免很多边界条件的处理。
3、Redis分布式锁的局限性
时钟漂移问题:
依赖系统时钟,多节点时钟不同步可能影响RedLock
持久化延迟:
异步复制可能导致主节点崩溃后从节点丢失锁信息
长时间阻塞:
获取不到锁的客户端需要合理处理等待/超时
4、对比
总结:
Redis分布式锁凭借其优异的性能和足够的可靠性,已成为互联网公司的首选方案。理解其实现原理和限制条件,能够帮助我们在不同业务场景中做出合理的技术选型。
参考文章: