广度成就多维视角,深度利于快速定位。 -- 微微一笑
引言
上一篇《Redis分布式锁使用及问题解决》我们通过场景切入,主要介绍了Redis分布式锁在并发场景下的使用。这一章从多种常用分布式锁的解决方案入手,深入探讨原理及应用场景。在技术广度上加入一些思考。主要内容如下:
分布式锁特性
分布式锁作为一种数据一致性同步机制,在设计和使用时需要具备哪特性来确保其可靠性和有效性?
- 原子性(Atomicity) : 分布式锁的获取和释放操作应该是原子的,确保在任何时刻只有一个节点能够成功获取锁,避免竞态条件。
- 可靠性(Reliability) : 分布式锁应该在各种异常情况下都能够可靠地工作,包括网络分区、节点故障等。即使在某个节点出现问题时,其他节点依然能够正常使用锁。
- 互斥性(Mutual Exclusion) : 分布式锁应该确保在任何时刻只有一个节点能够持有锁,其他节点必须等待或者尝试重新获取锁。
- 超时机制(Timeout) : 分布式锁通常应该支持超时机制,防止死锁情况的发生。如果一个节点获取锁后在一定时间内没有释放锁,系统应该能够自动释放锁。
- 可重入性(Reentrancy) : 有些分布式锁允许同一个节点多次获取同一把锁,即可重入性。这对于某些场景(比如递归调用)可能是有用的。
常见分布式锁解决方案
在分布式系统中,我们需要通过分布式锁控制多节点并发导致的数据一致问题。常见的分布式锁解决方案如下,我们通过详细的介绍,清晰的对比,简易的理解方式为读者打开新的视角。
分布式锁一: 数据库乐观锁(CAS思想)
先来大致了解下悲观锁 和乐观锁的区别:
在分布式系统中悲观锁易造成性能瓶颈。这里只介绍乐观锁的使用。还是先前的场景,用一张商品库存表,说明下如何理解数据库乐观锁。内容如下:
sql
mysql> select * from product_stock;
+----+------------+-------+---------+
| id | product_id | stock | version |
+----+------------+-------+---------+
| 1 | 1 | 100 | 1 |
+----+------------+-------+---------+
1 row in set (0.00 sec)
多线程环境下利用version版本号的机制,执行扣库存操作,基本流程如下:
流程描述:
- 事务A执行
sql
START TRANSACTION;
-- 查询当前版本号:1
SELECT version FROM product_stock WHERE product_id = 1;
-- 执行操作,更新版本号
UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = <当前查询到的版本号>;
-- 提交事务
COMMIT;
- 事务B执行
sql
START TRANSACTION;
-- 查询当前版本号:1
SELECT version FROM product_stock WHERE product_id = 1;
-- 执行操作,更新版本号,此处A执行完version=2,更新失败
UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = <当前查询到的版本号>;
-- 提交事务
COMMIT;
由此可见,乐观锁的方式能有效解决并发安全问题,适用于一些读多写少的场景,尤其是对于短事务、容忍性好的应用场景,它能够提高系统的并发性和吞吐量。但是并发量大的时候会导致大量的update失败。
分布式锁二: RedisTemplate: setNX expire
关于使用和常见问题,在上一篇《Redis分布式锁使用及问题解决》已经详细介绍,在使用中,我们曾经埋雷:redis分布式锁过期时间如何设置?接下来我们就聊聊Redission一些原理和使用中的问题。
分布式锁三: Redission框架
我们就上一篇中扣库存的案例,使用Redission框架再次升级。搭建一个简易版的单机环境,详细步骤:
- 添加 Maven 依赖:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.5</version> <!-- 最新版本请参考官方文档 -->
</dependency>
- 配置Redission
kotlin
@Configuration
public class RedisConfig {
@Value("${config.redis.host}")
private String host;
@Value("${config.redis.port}")
private String port;
@Value("${config.redis.password}")
private String password;
@Bean
public Redisson redisson() {
// 此为单机模式
Config config = new Config();
config.useSingleServer()
.setAddress("redis://"+host+":"+port)
.setPassword(password)
.setDatabase(1);
return (Redisson) Redisson.create(config);
}
}
- 业务逻辑
ini
@RequestMapping("/redissonLock/reduceStock")
public String reduceStock() {
String lockKey = "product_01";
RLock lock = redisson.getLock(lockKey);
String msg = "";
try {
//加锁
lock.lock();
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
redisTemplate.opsForValue().set("stock", realStock + "");
msg = "减库存成功,剩余:" + realStock;
System.out.println(msg);
} else {
msg = "减库存失败,库存不足";
System.out.println(msg);
}
} finally {
//释放锁
lock.unlock();
}
return msg;
}
- 测试结果
Pod:8080
Pod:8081
- 结果分析
显然,Redission框架确实解决了分布式环境并发产生的数据问题。我们提炼出核心逻辑:
csharp
// 创建分布式锁
RLock lock = redisson.getLock("myLock");
try {
// 尝试获取锁
lock.lock();
// 业务逻辑
System.out.println("Business logic inside the lock.");
} finally {
// 释放锁
lock.unlock();
}
非常简单易用。但世间蛮多简单的事情其实蛮复杂。正如:哪有什么岁月静好,只因为有人为你负重前行。 那么强大的背后是怎样的支撑?
我们先看下大致的流程,稍后通过源码深入原理:
流程描述:
- 客户端A
1、获取到锁;
2、判断是否设置过期时间,如果未指定锁的持续时间,则使用内部默认的持续时间
3、fork后台线程,定时任务续期;
4、执行完成,释放锁
- 客户端B
1、获取锁失败,因为锁被A持有;
2、while循环,以自旋方式不断尝试获取锁;
3、等待A释放锁,直到获取锁,否则继续步骤 2;
下面通过核心深入源码,详细了解上述流程,并思考几个问题.
这段代码是 Redisson 中用于尝试获取分布式锁的核心业务逻辑。
scss
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
//如果指定了加锁时间,加锁使用指定时间
if (leaseTime != -1) {
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 如果未指定锁的持续时间,则使用内部默认的持续时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
// 异步回调,处理锁获取结果
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
//省略部分代码...
// 锁获取成功
if (ttlRemaining == null) {
if (leaseTime != -1) {
// 更新内部锁持续时间
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 启动定时任务,定期续约锁的过期时间
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
1)加锁逻辑详细说明tryLockInnerAsync
arduino
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
// 使用 Lua 脚本执行原子性操作获取锁
"if (redis.call('exists', KEYS[1]) == 0) then " + // 如果锁不存在
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 将线程ID作为哈希字段,并递增其值
"redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置锁的过期时间
"return nil; " + // 返回 nil 表示锁获取成功
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + // 如果线程ID已存在
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 递增线程ID的值
"redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置锁的过期时间
"return nil; " + // 返回 nil 表示锁获取成功
"end; " +
"return redis.call('pttl', KEYS[1]);",// 返回锁的剩余过期时间
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
标注:
在这段 Lua 脚本中,KEYS[1]、ARGV[1] 和 ARGV[2] 是 Lua 脚本中的参数,对应 evalWriteAsync 方法中传递的参数。
- KEYS[1]: 代表第一个参数,对应 Collections.singletonList(getRawName()),即 Redis 锁的名称,也就是键值(key)。
- ARGV[1]: 代表第一个额外参数,对应 unit.toMillis(leaseTime),即以毫秒为单位的锁的持续时间。
- ARGV[2]: 代表第二个额外参数,对应 getLockName(threadId),即获取锁的线程ID。
2)锁续期:scheduleExpirationRenewal(threadId).方法调用链路:scheduleExpirationRenewal --> renewExpiration。提供简化代码:
scss
//开启一个定时任务执行续期逻辑
private void renewExpiration() {
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//省略部分代码...
// 异步执行续约操作
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
//省略部分代码...
if (res) {
// 如果续约成功,重新调自身执行续约操作
renewExpiration();
} else {
cancelExpirationRenewal(null);
}
});
}
// 注意:跟踪代码发现:这里定时任务:lockWatchdogTimeout 的1/3 = 10S 时间去执行
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
3)几个细节
- 加锁逻辑中的参数
scss
Collections.singletonList(getRawName()) = LockKey
getLockName(threadId) == UUID:threadId
- WatchDog默认时间30s,每隔:lockWatchdogTimeout/3 = 10s执行一次定时续期。
ini
private long lockWatchdogTimeout = 30 * 1000;
internalLockLeaseTime / 3
- 异步思想:通过Future异步线程回调,主线程执行业务逻辑,后台线程执行续期逻辑,减少阻塞
就上述流程,我们做个小总结盘下几个问题:
Q1:如何保证加锁操作原子性?
使用Lua脚本(天生原子)执行加锁逻辑,Redis-Server端自己维护。例如,上述代码中使用Lua脚本执行判断+加锁+超时设置。
kotlin
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;
Q2:如何自动续期?
获取锁成功后,利用看门狗机制每隔10s中执行一次续期。
java
private void renewExpiration() {
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// 异步执行续约操作
future.onComplete((res, e) -> {
if (res) {
// 如果续约成功,回调自身执行续约操作
renewExpiration();
}
});
}
// 定时任务:lockWatchdogTimeout 的1/3 = 10S 时间去执行
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
Q3:可重入如何理解?
先看下可重入锁的定义,我们知道synchronized 关键字在 Java 中天生支持可重入锁。
简单的示例代码如下:
csharp
public class ReentrantLockDemo {
public static void main(String[] args) {
// 创建一个示例对象
ReentrantObject reentrantObject = new ReentrantObject();
// 启动一个线程
new Thread(() -> {
try {
reentrantObject.performTask();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
class ReentrantObject {
// 定义一个可重入锁
private final Object lock = new Object();
public void performTask() throws InterruptedException {
// 第一次获取锁
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " 第一次获取锁");
// 在持有锁的情况下再次获取锁
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " 第二次获取锁");
// 执行任务,模拟工作
Thread.sleep(1000);
}
}
// 释放第一次获取的锁
System.out.println(Thread.currentThread().getName() + " 第一次释放锁");
}
}
测试结果:
当一个线程持有一个对象的锁时,它可以再次获取相同对象的锁,而不会被阻塞。可重入锁会维护一个持有锁的计数器。当线程第一次获取锁时,计数器值加一;当线程释放锁时,计数器值减一。只有当计数器值为零时,表示锁被完全释放,其他线程才有机会获取锁。
那么在分布式环境中,如何实现可重入?
在 Redisson 的源码中,可重入锁的实现主要涉及到 Redis 的 Lua 脚本和底层的 Redis 命令。(上面已介绍,看看是那段,哈哈~~~)
简单概括一下 Redisson 中可重入锁的主要实现步骤:
1、创建锁对象: 当用户请求创建一个可重入锁时,Redisson 会在 Redis 中创建一个相应的数据结构表示该锁。这个数据结构通常是一个哈希表。
2、线程标识: 每个请求锁的线程都有一个唯一的标识符。Redisson 使用线程 ID 来标识不同的线程。
3、计数器: 在 Redisson 中,可重入锁的计数器用于记录某个线程持有锁的次数。当线程首次请求锁时,计数器被设置为 1。每次重入请求都会增加计数器。
4、Lua 脚本: Redisson 使用 Lua 脚本来实现原子性的获取锁和释放锁的操作。Lua 脚本可以确保这两个操作是原子的,从而避免了并发问题。
5、获取锁: 获取锁时,Lua 脚本会检查当前线程是否已经持有锁。如果是,则增加计数器。如果不是,则尝试获取锁。获取锁的过程包括判断是否锁已经被其他线程持有,如果没有,则设置锁的所有者和计数器。
6、释放锁: 释放锁时,Lua 脚本会检查当前线程是否持有锁。如果是,则减少计数器。如果计数器减为 0,表示锁可以被释放。释放锁的过程包括判断是否锁的所有者是当前线程,如果是,则释放锁。
Q4:Redis主从架构中锁失效问题?
如,生产环境redis集群主节点宕机,如下场景:
- Client-A在master节点获取锁成功。还没有把获取锁的信息同步到 slave 的时候,master 宕机。
- slave 被选举为新 master,这时候没有线程A获取锁的数据。
- Client-B 就能成功的获得客户端A持有的锁,违背分布式锁互斥性。
为保证数据强一致性,通常有些使用Zookeeper实现分布式锁
分布式锁四: Zookeeper + Curator实现分布式锁
标注:Curator是Zookeeper一个客户端,类似于Redission和Redis的关系。
主要利用Zookeeper的节点唯一路径去实现.其主要原理:
逻辑参考ZK原理和网络文章:juejin.cn/post/703859...
详细描述:
(1)线程到 /locks 路径下面创建一个带序号的临时节点。
(2)判断自己创建的这个节点是不是/locks路径下序号最小的节点,如果是,则获取锁;如果不是最小节点,则监听自己的前一个节点。
(3)获取到锁后,执行业务逻辑,然后删除自己创建的节点。监听它的后一个节点收到通知后,执行步骤(2)
使用ZooKeeper和Curator实现分布式锁的基本步骤:
1、引入Curator依赖
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.1.0</version> <!-- 请检查最新版本 -->
</dependency>
2、创建Curator客户端: 创建一个CuratorFramework实例来连接到ZooKeeper服务器
ini
CuratorFramework client = CuratorFrameworkFactory.newClient("your_zookeeper_connection_string", new ExponentialBackoffRetry(1000, 3));
client.start();
3、使用分布式锁: Curator提供了InterProcessMutex类来实现分布式锁
csharp
InterProcessMutex lock = new InterProcessMutex(client, "/locks/path");
try {
// 获取锁
lock.acquire();
// 执行其它
} catch (Exception e) {
// 处理异常
} finally {
// 释放锁
lock.release();
}
在上述代码中,"/locks/path"是ZooKeeper中的节点路径,用于存储锁信息
4、处理锁超时: 在获取锁时,你可以选择传递超时时间。
csharp
//这样可以避免死锁,如果在指定时间内无法获取锁,可以选择执行其他逻辑。
if (lock.acquire(5, TimeUnit.SECONDS)) {
try {
// 执行其它
} finally {
// 释放锁
lock.release();
}
} else {
// 获取锁超时的处理逻辑
}
5、关闭Curator客户端: 在应用程序关闭时,确保关闭CuratorFramework客户端
go
client.close();
总结
综上,对各种分布式锁的使用我们给出如下建议:
- 数据库乐观锁
-
优点
- 通过版本号或时间戳等字段的比对,实现相对简单
- 支持跨进程,适用于分布式环境,可以跨多个数据库实例使用
-
缺点
- 性能: 由于需要比对版本号或时间戳,并发量大会导致大量update失败
- 死锁: 如果应用层未正确处理死锁,可能出现死锁问题。
- Redis的setNX+EXPIRE
-
优点
- Redis的原子性操作,实现简单而高效
- 支持分布式: 可以轻松在分布式环境中使用
-
缺点
- 单点故障
- 当锁的持有者因某种原因宕机,锁可能长时间无法释放
- Redisson
- 优点
- 支持可重入锁、公平锁、读写锁等
- ZooKeeper
-
优点
- 强一致性,适用于对一致性要求较高的场景。
- 高可用性,支持主从架构,半数以上选举机制保证在主节点失效时仍能提供服务。
-
缺点
- 性能相对较低: 相比于Redis等内存数据库,ZooKeeper的性能可能相对较低。CP原理。性能和一致性只能取其一。
- 复杂性: 部署和维护ZooKeeper集群相对复杂。
值得注意的是:他们都有共性的问题,就是 死锁: 所有分布式锁的实现都需要注意死锁的问题,即锁被永久性地占用。
结尾
感知生活:一半有用,一半有趣。 感谢耐心的你阅读到最后。希望本篇文章对你有帮助,也欢迎你加入我们,公众号【码易有道】。一起做长期且正确的事情!!!