1. 概述:为什么需要分布式锁?
在单机Java应用中,我们通常使用内置锁(如 synchronized 或 ReentrantLock)来解决多线程间的资源竞争问题,保证在同一时刻只有一个线程可以访问临界资源。
然而,当应用演进到 集群部署 、微服务架构 时,情况发生了根本变化:
-
应用部署在多台服务器上,每个服务器都是一个独立的JVM进程。
-
单机锁只能控制当前JVM内的线程同步,无法跨JVM生效。
-
用户请求通过负载均衡(如Nginx)被随机分发到不同的应用实例上。
这时,单机锁就完全失效了。分布式锁 正是为了解决 跨进程 、跨服务 、跨机器 的互斥访问问题而生的技术方案。它的核心思想是引入一个所有进程都能访问的 外部协调系统(如Redis、ZooKeeper),由这个系统来统一管理锁的获取和释放,充当一个所有JVM都认可的 "权威法官"。
2. 需要使用分布式锁的现实场景举例
2.1 电商库存超卖问题(最经典案例)
场景描述:
假设一个电商平台举行iPhone秒杀活动,限量1000台。后端服务为了应对高并发流量,部署了10个实例。
问题根源:
在没有分布式锁的情况下,扣减库存的典型逻辑是:
-
从数据库查询当前库存(select stock from product where id = #{})。
-
判断库存是否大于0(if(stock > 0))。
-
如果充足,则执行扣减(update product set stock = stock - 1 where id = #{})。
在极高并发下,两个请求可能同时到达 服务器A 和 服务器B。它们几乎同时执行第1步,都读到了库存 stock = 1000,都判断为充足,然后都执行了扣减。最终数据库库存变成了999,但实际卖出了2台,导致了"超卖"。严重时,库存甚至会被扣成负数。
分布式锁解决方案:
在执行扣减库存逻辑前,先尝试获取一个以商品ID为Key的分布式锁。
java
// 伪代码示例,使用Redis分布式锁
String lockKey = "product_lock:" + productId;
String requestId = UUID.randomUUID().toString(); // 唯一标识,防误删
try {
// 尝试获取锁
Boolean locked = redisClient.setNx(lockKey, requestId, 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,执行查询、判断、更新的完整业务逻辑
doDeductInventory(productId);
} else {
// 获取锁失败,可重试或直接返回"抢购失败"
throw new RuntimeException("抢购人数过多,请重试");
}
} finally {
// 释放锁:判断是不是自己加的锁,避免误删
if (requestId.equals(redisClient.get(lockKey))) {
redisClient.del(lockKey);
}
}
这样,对同一个商品的并发操作会被串行化,从而避免超卖。
2.2 防止重复处理(实现幂等性)
场景描述:
支付成功后,第三方支付平台会异步回调你的接口通知支付结果。由于网络不确定性,支付平台可能会重试,导致你的接口收到多次完全相同的回调请求。
问题根源:
如果处理回调的逻辑不是幂等的,比如:
java
// 非幂等性操作
public void callback(Order order) {
// 1. 根据订单号更新订单状态为"已支付"
updateOrderStatus(order.getNo(), PAID);
// 2. 为用户增加积分
addUserPoints(order.getUserId(), order.getAmount());
// 3. 发送成功通知
sendSuccessMessage();
}
那么处理一次回调和执行两次回调的结果是不同的:用户积分会加两次,通知会发两次,订单状态虽然一样,但后续逻辑可能出错。
分布式锁解决方案:
在处理回调业务前,先获取一个以业务标识(如订单号)为Key的分布式锁。
java
public void callback(Order order) {
String lockKey = "order_callback:" + order.getNo();
try {
// 尝试获取锁,成功说明是第一次处理
if (tryLock(lockKey)) {
// 正常的业务处理
processOrder(order);
} else {
// 获取锁失败,说明正在处理或已处理过,直接返回成功即可
log.info("重复回调请求,订单号:{}", order.getNo());
return "success";
}
} finally {
releaseLock(lockKey);
}
}
2.3 集群环境下的定时任务调度
场景描述:
有一个每天凌晨1点执行的任务,用于统计前一天的销售额并生成报表。应用部署了3个实例(A, B, C)。
问题根源:
如果没有控制,到了凌晨1点,A、B、C三个实例上的定时任务会同时启动,生成三份一模一样的报表,浪费计算资源,甚至可能导致数据统计错误(如果统计逻辑涉及状态更新)。
分布式锁解决方案:
在任务执行开始时,尝试获取一个唯一的分布式锁(如JOB:STATISTICS:20240531)。
java
@Scheduled(cron = "0 0 1 * * ?")
public void dailyStatisticsJob() {
String jobName = "JOB:STATISTICS";
if (!distributedLockService.tryLock(jobName, 5, TimeUnit.MINUTES)) {
log.info("其他实例正在执行该任务,本实例跳过执行");
return;
}
try {
// 获取锁成功,执行核心任务逻辑
doStatistics();
} finally {
distributedLockService.unlock(jobName);
}
}
这样就能保证整个集群中,该任务在每天凌晨1点只会被成功执行一次。
2.4 实现分布式系统协同
场景描述:
- 全局开关: 系统需要一个"维护模式"开关,一旦开启,所有实例都需要暂停某些服务。
- 主节点选举: 在集群中,需要选举出一个主节点(Master)来执行某些特殊的管理任务。
问题根源:
在分布式系统中,各个节点无法直接感知到其他节点的状态,需要一个中心化的协调机制。
分布式锁解决方案:
利用分布式锁的互斥特性来实现协同。
-
维护模式: 管理员操作一个接口,该接口会尝试获取一个名为"SYSTEM_MAINTENANCE_LOCK"的分布式锁。获取成功即进入维护模式,并设置一个全局标志。其他节点在执行任何操作前,都检查这个锁是否存在或检查全局标志。
-
主节点选举: 所有节点启动时都尝试去创建同一个锁(如"CLUSTER_MASTER")。最终只有一个节点能创建成功(即获取锁),该节点就成为Master。其他节点作为Slave。如果Master宕机,锁因超时而被释放,其他节点可以重新竞争获取锁,从而实现故障转移。
3. 实现分布式锁的常见技术选型
技术方案 | 实现原理 | 优点 | 缺点 |
---|---|---|---|
基于 Redis | 使用 SET key value NX PX 命令原子性地设置带过期时间的键。 | 性能极高,读写速度快,延迟低。 | 需要注意 锁误删、过期时间设置 等问题(可用Redisson客户端解决可重入、看门狗续期)。 |
基于 ZooKeeper | 利用 临时顺序节点(Ephemeral Sequential Node)和Watch机制。 | 可靠性高,强一致性;临时节点在客户端断开后自动删除,无超时问题。 | 性能相比Redis较低,部署和运维更复杂。 |
基于数据库 | 利用数据库的 唯一约束 或 悲观锁(SELECT ... FOR UPDATE)。 | 实现简单,依赖少。 | 性能最差,对数据库压力大,在高并发下容易成为瓶颈,一般不推荐使用。 |
建议: 对于绝大多数并发场景,Redis 是首选,尤其是使用 Redisson 库,它提供了丰富且线程安全的分布式锁API。对可靠性要求极高、可以牺牲一些性能的场景,可以考虑 ZooKeeper 。|
4. 总结
在Java开发中,当你从单机应用迈向集群或微服务架构时,就是考虑分布式锁的时刻。 它的核心应用场景可以归结为:
- 资源竞争: 如库存扣减、余额修改等,需要保证数据最终一致性的场景。
- 幂等控制: 如网络重试、消息重复消费,需要避免重复业务操作的场景。
- 任务调度: 在集群中确保定时任务只被执行一次。
- 系统协同: 如主从选举、全局状态同步等。
选择分布式锁方案时,需要在 性能(Redis) 和 可靠性(ZooKeeper) 之间做出权衡。理解其应用场景和原理,是构建稳定、可靠分布式系统的关键一步。