分布式锁
简介
在单机部署的应用中,我们通常使用 synchronized 或 java.util.concurrent.locks.Lock 等本地锁机制来保证多线程对共享资源的互斥访问,但只在单个 JVM 进程内有效。
然而,随着业务的发展,现代大型系统往往采用分布式架构,将应用部署在多个节点上(不同物理机或容器),多个进程独立运行,此时共享资源的竞争就不再局限于单机内部。
例如,在电商秒杀活动中,多个服务节点可能同时处理同一个商品的库存扣减;在分布式任务中,需要确保同一时刻只有一个节点执行某个任务。在这种跨进程、跨网络的场景下,传统的本地锁无法协调不同节点上的线程,因此需要一种新的机制——分布式锁。
分布式锁是一种跨进程的互斥原语,它通过在多个进程间协调对共享资源的访问,确保在任意时刻,只有一个客户端(或线程)能够持有锁并执行临界区代码。它本质上是一个在分布式系统中所有节点都能访问的“协调者”,所有需要访问共享资源的节点都必须先向这个协调者请求锁,成功获取锁的节点才能继续操作,操作完成后释放锁,让其他节点竞争。这个“协调者”可以是数据库、缓存中间件(如 Redis)、协调服务(如 ZooKeeper、etcd)等。
分布式锁应具备的特性
互斥性(最核心):互斥性是分布式锁最基本的要求,也是分布式锁的根本目的。在任何时刻,绝对只能有一个客户端持有锁,其它客户端必须等待锁释放后才能竞争。锁服务必须提供严格的排他机制,确保多个并发请求中只有一个能成功获得锁。通常通过原子操作(如 Redis 的 SET NX)或临时顺序节点(如 ZooKeeper)来实现。防死锁(高可用基础):死锁是分布式系统中必须避免的情况。即使持有锁的客户端因崩溃、网络分区或其他故障而无法主动释放锁,锁也应该能被自动清除,以便其他客户端能够继续获取锁。常见的防死锁手段为超时机制:为锁设置一个合理的过期时间(TTL),当客户端未在过期时间内完成操作并释放锁时,锁服务自动将其释放。持有者标识:释放锁时必须验证当前操作者是否是锁的持有者,防止误释放其它客户端持有的锁(例如因超时释放后,后续操作被另一个客户端获取,原客户端突然恢复并释放了新的锁)。可重入性(根据需要,可选):可重入性允许同一个客户端在已经持有锁的情况下,再次请求同一把锁而不会被阻塞(类似 Java 的 ReentrantLock)。这对于需要递归调用或多次进入同一同步块的业务逻辑非常有用。实现可重入通常需要锁服务记录持有者的标识(如线程 ID + 机器 ID)以及重入次数。高性能:分布式锁是许多高频操作的基础组件,其性能直接影响业务响应速度。锁的获取和释放应尽可能轻量,避免过多的网络交互或复杂的计算。例如,基于内存的存储(如 Redis)通常比基于磁盘的存储(如数据库)性能更高。同时,锁的设计要尽量减少对锁服务本身的压力,避免成为系统瓶颈。高可用:锁服务本身必须具有高可用性。如果提供分布式锁的服务崩溃,那么整个依赖锁的业务系统将无法正常工作。因此,锁服务通常需要采用集群部署,具备数据复制和故障转移能力。例如,Redis 的哨兵或集群模式、ZooKeeper 的多数派机制等,可以保证在部分节点故障时锁服务仍能正常提供。锁的自动续期:业务逻辑的执行时间可能难以预估,如果仅仅依赖固定的过期时间,容易出现锁提前释放导致并发冲突。因此,很多分布式锁方案引入了“看门狗”机制:在锁获取后,启动一个守护线程定期为锁续期,直到客户端主动释放锁或客户端崩溃。这样既保证了业务有充足的时间执行,又避免了死锁。心跳续期:持有锁的客户端通过后台线程定期续期,避免锁因业务执行时间过长而过期。这种方式可以有效防止因业务耗时过长导致的锁提前释放。可阻塞与公平性:可阻塞:当锁被持有时,后续请求可以阻塞等待,直到锁被释放,而不是立即返回失败。公平性:按照请求锁的顺序分配锁,避免某些请求长期饥饿。实现公平锁通常需要排队机制(如 ZooKeeper 的顺序节点)。易于理解和监控:一个好的分布式锁组件应该提供清晰的 API 和监控能力,方便开发者集成以及运维人员排查问题。例如,能够查看当前锁的持有者、锁的等待队列、锁的获取次数等指标。
常见实现方式
基于数据库
利用数据库的唯一索引或排他锁来实现。
基于唯一索引:在表中创建锁资源对应的唯一索引,客户端尝试插入一条记录来获取锁,插入成功即获得锁;释放锁时删除该记录。需要配合定时任务清理过期记录以避免死锁。基于排他锁:使用SELECT ... FOR UPDATE,在事务中查询锁记录并加上行锁,事务提交后释放锁。
优点:实现简单,无需引入额外中间件。
缺点:性能较差,依赖数据库可用性,可能产生单点问题;锁超时不易控制。
基于 Redis
Redis 凭借其高性能和原子操作,成为实现分布式锁的常用方案。最经典的是 Redlock 算法,但多数场景下使用单节点 Redis + 原子命令即可。
核心命令:SET key value NX PX milliseconds
NX:仅当 key 不存在时设置,保证互斥。PX:设置过期时间,防止死锁。
释放锁:使用 Lua 脚本确保原子性,先检查锁持有者(value 通常为唯一标识),再删除。
Redisson 是 Redis 官方推荐的 Java 客户端,提供了丰富的锁 API,支持可重入锁、公平锁、读写锁等,并自动续期(看门狗机制)。
优点:性能极高,实现相对简单。
缺点:依赖 Redis 高可用,主从切换可能导致锁丢失(Redlock 试图解决但仍有争议)。
为什么使用set nx 实现分布式锁不安全? 因为使用 set nx时, 必然要执行 delete 命令, 如果在线程任务(方法)未执行到 detele 命令前抛出了异常, 则锁被永远锁住了, 除非手动删除,否则永远释放不了了,造成死锁。
那将delete放到finally里边呢? 能解决上述问题么? 也不行, 首先 finally代码块里的代码,不是一定会执行,最简单的,当执行finally代码块时断电了呢, JVM进程异常了呢等等原因。所以不能完全依靠finally代码块+set nx 来实现分布式锁
那再 set nx 之后,再 set expire 设置过期时间呢? 也不行, 因为 set nx 和 expire 是两条 redis 命令, 不是原子性的,比如set nx设置成功了,当设置expire时服务器宕机了,最终还是会造成死锁,所以不能完全保证。 当然,redis目前支持 set nx expire 当做一条原子性命令来执行,有对应的方法, 或者用 lua 脚本实现也可以,
但是如果过期时间到了,比如expire设置了10秒,但10秒过后业务代码还没有执行完成,结果锁过期了,也可能导致锁错乱。
所以需要实现自己加的锁自己来释放,
@PostMapping("/deduct_stock/setnxexpire")
public String deductStockWithSetnxExpire() throws Exception {
// 当前请求的唯一ID
String clientId = UUID.randomUUID().toString();
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY_PRODUCT_1, clientId, 10, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(ifAbsent)) {
throw new Exception("系统繁忙,请稍后再试");
}
try {
String redisStock = stringRedisTemplate.opsForValue().get(STOCK_KEY_PRODUCT_1);
int stock = redisStock == null ? 0 : Integer.parseInt(redisStock);
System.out.println("当前库存:" + stock);
if(stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set(STOCK_KEY_PRODUCT_1, realStock + ""); // jedis.set(key, value);
System.out.println("扣减成功,剩余库存:" + realStock);
return "扣减成功,剩余库存:" + realStock;
} else {
System.out.println("扣件失败,库存不足");
throw new Exception("扣件失败,库存不足");
}
} finally {
if(clientId.equals(stringRedisTemplate.opsForValue().get(LOCK_KEY_PRODUCT_1))) {
// 比如线程1执行到该行时,卡顿了,10秒到期后锁已经过期被删除, 而此时线程2已经成功获取了锁
// 过期后线程1恢复并执行了改行代码,则线程1删除了线程2获取的分布式锁
stringRedisTemplate.delete(LOCK_KEY_PRODUCT_1);
}
}
}以上代码依然有问题, 比如线程1执行完if(clientId.equals(stringRedisTemplate.opsForValue().get(LOCK_KEY_PRODUCT_1)))行时,卡顿了,10秒到期后锁已经过期被删除,而此时线程2已经成功获取了锁,过期后线程1恢复并执行了stringRedisTemplate.delete(LOCK_KEY_PRODUCT_1);代码,则线程1删除了线程2获取的分布式锁。当然也可以通过lua脚本来实现原子性。但还是那句话,finally代码块的代码并不一定会执行。
redisson的实现逻辑 lock方法,首先通过lua脚本尝试去获取锁,如果获取成功,则redis.hset lock_name UUID+线程编号,设置为1(hash结构, key是锁的名称,field是UUID_线程编号, value是冲入次数,初始为1),这整体是一个Future方法,然后添加一个监听器,如果成功,则添加一个后台线程(单独的方法)(这个具体的添加逻辑是在过期时间/3秒之后再执行),具体逻辑为判断hash中是否还有该字段,如果有,则过期时间重新设置为初始值。并且重新添加一个后台线程(再次调用这个单独的方法)
在unlock中,也是判断,如果hash中存在field,则del,并在channelName(可以理解为一个topic或者queue, channelName是固定前缀+锁名称,在getLock中实现的),删除后则会往channel放一条消息, 并返回true。
在lock方法中,如果获取锁失败,则会返回当前持有锁的剩余时间,如果剩余时间大于0,则会订阅这个channel(也是Future实现),通过while循环,间歇性的去获取锁,首先获取失败后会再次尝试立刻去获取一次,如果还是获取失败,则获取执行许可(通过Semaphore实现),此时会释放CPU,线程等待,等待时长为当前锁持有的剩余时间。如果获取成功,则解除channel订阅。当然,获取锁成功后持有的默认时长是30秒,但如果10秒已经执行完成, 在unlock中已经往channel放入了一条消息,因为未成功获取的线程订阅了channel,当channel有消息时,会执行LockPubSub的onMessage方法,调用Semaphore的release方法,来激活等待的线程重新去获取锁,所以由此可见redison是非公平的。当前,之所以采用hash结构,大概是因为需要支持可重入吧,每次unlock时,会判断value是否=1,如果是,则del,并发送message,否则仅-1。
基于 ZooKeeper
ZooKeeper 是一种分布式协调服务,利用其临时顺序节点和 Watch 机制可实现分布式锁。
原理:
- 客户端在锁路径下创建临时顺序节点。
- 获取所有子节点,判断自己是否是最小序号节点。
- 若是,则获得锁;否则监听前一个节点的删除事件。
- 释放锁时删除自身临时节点,后继节点收到通知后尝试获取锁。
优点:天然支持顺序性、Watch 机制可避免轮询;节点临时特性可防止死锁。
缺点:性能低于 Redis(磁盘写、选举),客户端需维持长连接。
实现方案对比
| 特性 | 数据库 | Redis | ZooKeeper |
|---|---|---|---|
| 性能 | 低 | 高 | 中 |
| 可靠性 | 取决于数据库,可能出现单点 | 取决于主从/集群,可能发生锁丢失 | 高(ZAB 协议保证一致性) |
| 实现复杂度 | 简单 | 中等(需处理原子释放、续期) | 中等(利用 Curator 简化) |
| 死锁防护 | 需额外定时清理 | 过期时间 + 看门狗 | 临时节点自动清理 |
| 可重入 | 需额外字段实现 | Redisson 支持 | Curator 支持 |
| 适用场景 | 对性能要求不高,不想引入新中间件 | 高并发,可接受极小概率锁丢失 | 对一致性要求极高,如金融、配置中心 |
代码示例(基于 Redis + Redisson)
// 引入 Redisson 依赖(Maven)
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.0</version>
</dependency>
// 配置 Redisson 客户端
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
// 使用分布式锁
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(String orderId) {
String lockKey = "lock:order:" + orderId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待 10 秒,锁自动释放时间为 30 秒
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 执行业务逻辑
System.out.println("处理订单:" + orderId);
Thread.sleep(5000); // 模拟耗时
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放锁(需确保当前线程持有锁)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}