分布式锁的特点
在分布式系统中,“并发安全”是绕不开的坎——多个服务节点同时操作同一资源时,很容易出现数据错乱。比如电商系统的库存扣减,若不加以控制,就可能出现超卖;分布式任务调度时,若多个节点同时执行同一任务,会造成资源浪费。
而分布式锁,就是解决这类问题的“钥匙”。它能像单机系统中的锁一样,保证在分布式环境下,同一时刻只有一个节点能操作临界资源。但分布式锁的实现远比单机锁复杂,既要保证互斥性,还要应对节点崩溃、网络中断等异常场景。
今天这篇文章,就从“核心特性-实现方式-使用场景-问题解决”四个维度,把分布式锁讲透。重点解析数据库、Redis、ZooKeeper三种主流实现方案的优劣,以及生产环境中的避坑技巧。
一、先搞懂:分布式锁的3个核心特性
不是随便一个“加锁逻辑”都能叫分布式锁,它必须满足三个核心特性,这也是面试中常考的基础考点:
1.1 互斥性(最核心)
这是锁的本质功能:同一时刻,只有一个客户端能持有锁。比如两个服务节点同时请求扣减同一商品的库存,只有一个节点能成功获取锁并执行操作,另一个必须等待。
这里要注意“客户端”是广义的,可能是一个服务实例、一个线程,具体范围可根据业务场景定义,但核心是“同一资源同一时间仅被一个操作者占用”。
1.2 容错性(避坑关键)
分布式系统中,节点崩溃、网络中断是常态,分布式锁必须能应对这些异常:即使持有锁的客户端崩溃或失去连接,锁也要能被正常释放,避免死锁。
比如某节点获取锁后突然宕机,若锁无法释放,其他节点将永远无法获取该锁,导致业务阻塞。这也是分布式锁和单机锁最大的区别——单机锁依赖进程内资源,而分布式锁要应对跨节点的异常。
1.3 高可用性(生产必备)
锁服务本身不能成为“单点故障”:负责管理锁的服务(如Redis、ZooKeeper)必须高可用,即使部分节点故障,锁服务仍能正常工作。
比如用单节点Redis实现分布式锁,若Redis节点宕机,所有依赖该锁的业务都会瘫痪。所以生产环境中,锁服务的部署必须考虑集群容错。
二、三大实现方式深度解析:优劣对比+实战代码
分布式锁的实现方式有很多,其中数据库、Redis、ZooKeeper是最主流的三种。它们各有优劣,适用不同的业务场景,下面逐个拆解。
2.1 方式一:基于数据库的分布式锁(最原始,少用)
数据库实现分布式锁的核心思路是“利用数据库的行级锁或唯一约束”,强制同一时刻只有一个客户端能操作特定资源。最常见的有两种方案:SELECT ... FOR UPDATE行锁和唯一索引约束。
方案1:SELECT ... FOR UPDATE行锁
先创建一张锁表,存储资源标识和持有锁的客户端信息,然后通过SELECT ... FOR UPDATE语句获取行锁,实现互斥。
- 创建锁表:
-- 锁表:resource字段存储资源唯一标识,holder存储持有锁的客户端ID
CREATE TABLE distributed_lock (id bigint NOT NULL AUTO_INCREMENT,resource varchar(64) NOT NULL COMMENT '资源标识',holder varchar(64) NOT NULL COMMENT '持有锁的客户端ID',create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (id),UNIQUE KEY uk_resource (resource) COMMENT '资源唯一约束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;- 获取锁:
通过SELECT ... FOR UPDATE锁定指定资源的行,若该资源已被其他客户端锁定,当前请求会阻塞等待:
// 1. 先插入资源记录(若不存在),避免死锁
String insertSql = "INSERT IGNORE INTO distributed_lock (resource, holder) VALUES (?, ?)";
jdbcTemplate.update(insertSql, "product:stock:1001", "client_123");
// 2. 获取行锁:FOR UPDATE会锁定该资源对应的行
String lockSql = "SELECT * FROM distributed_lock WHERE resource = ? FOR UPDATE";
jdbcTemplate.query(lockSql, new Object[]{"product:stock:1001"}, rs -> {// 锁获取成功,执行业务逻辑return null;
});- 释放锁:
业务执行完成后,删除对应的锁记录(或更新holder为空):
String unlockSql = "DELETE FROM distributed_lock WHERE resource = ? AND holder = ?";
jdbcTemplate.update(unlockSql, "product:stock:1001", "client_123");方案2:唯一索引约束
利用唯一索引的“唯一性”特性:插入资源标识对应的记录,若插入成功,说明获取锁;若因唯一约束报错,说明锁已被其他客户端持有。
// 获取锁:插入成功即获锁,失败则锁已被持有
try {String insertSql = "INSERT INTO distributed_lock (resource, holder) VALUES (?, ?)";jdbcTemplate.update(insertSql, "product:stock:1001", "client_123");// 锁获取成功
} catch (DuplicateKeyException e) {// 锁已被其他客户端持有,获取失败throw new RuntimeException("获取锁失败");
}// 释放锁:删除记录
String unlockSql = "DELETE FROM distributed_lock WHERE resource = ? AND holder = ?";
jdbcTemplate.update(unlockSql, "product:stock:1001", "client_123");数据库锁的优劣与适用场景
优势 | 劣势 | 适用场景 |
实现简单,无需额外中间件 | 性能低:高并发下数据库压力大,行锁会导致大量阻塞 | 并发量低、业务简单的场景,或没有Redis/ZooKeeper的小型系统 |
依赖数据库原有高可用方案(如主从复制) | 容错性差:客户端崩溃未释放锁时,需额外定时任务清理 | 非核心业务,对性能要求不高的场景 |
2.2 方式二:基于Redis的分布式锁(最主流,推荐)
Redis凭借其高性能、原子操作特性,成为分布式锁的首选方案。核心思路是“利用SET命令的NX(不存在才设置)特性实现互斥,配合过期时间避免死锁”。
注意:不能用“SETNX + EXPIRE”两步操作,因为两步操作不是原子的,若SETNX成功后客户端崩溃,EXPIRE未执行,会导致锁永远无法释放。必须用Redis 2.6.12+支持的“SET NX EX”原子命令。
基础实现:SET NX EX原子命令
- 获取锁:
使用SET命令,同时指定NX(不存在才设置)、EX(过期时间)和锁值(用于释放锁时校验):
import redis.clients.jedis.Jedis;public class RedisLock {private static final String LOCK_KEY_PREFIX = "distributed:lock:";private static final int LOCK_EXPIRE = 30; // 锁默认过期时间30秒private Jedis jedis;
// 构造方法注入Jedis客户端
public RedisLock(Jedis jedis) {this.jedis = jedis;
}// 获取锁:返回锁值(用于释放锁校验),获取失败返回null
public String tryLock(String resource) {String lockKey = LOCK_KEY_PREFIX + resource;String lockValue = java.util.UUID.randomUUID().toString(); // 唯一锁值,避免误删其他客户端的锁// 原子操作:NX(不存在才设置)、EX(过期时间30秒)String result = jedis.set(lockKey, lockValue, "NX", "PX", LOCK_EXPIRE * 1000);// 返回OK说明获取锁成功,返回锁值用于后续释放return "OK".equals(result) ? lockValue : null;
}}`
- 释放锁:
释放锁时必须校验锁值,避免误删其他客户端的锁。同时要保证“校验+删除”是原子操作,需用Lua脚本实现:
//释放锁:传入资源标识和获取锁时的锁值
public boolean unlock(String resource, String lockValue) {if (lockValue == null) {return false;}String lockKey = LOCK_KEY_PREFIX + resource;// Lua脚本:先校验锁值是否匹配,匹配则删除(原子操作)String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = (Long) jedis.eval(luaScript, 1, lockKey, lockValue);
return result == 1;
}`进阶优化:引入看门狗机制(解决锁过期问题)
基础实现有个隐患:若业务执行时间超过锁的过期时间,锁会被自动释放,导致并发安全问题。比如锁过期时间30秒,业务执行了40秒,锁释放后其他客户端会获取到锁。
解决方案就是看门狗机制(Redisson已封装完善):客户端获取锁后,启动一个后台线程,每隔锁过期时间的1/3(如10秒)检查一次,若锁仍被持有,则延长锁的过期时间。
用Redisson实现带看门狗的Redis锁(推荐生产使用):
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;public class RedissonLockDemo {private RedissonClient redissonClient;public RedissonLockDemo(RedissonClient redissonClient) {this.redissonClient = redissonClient;}public void doBusinessWithLock(String resource) {// 1. 获取锁:默认启用看门狗,过期时间30秒,自动续期RLock lock = redissonClient.getLock("distributed:lock:" + resource);try {// 2. 加锁:阻塞等待,直到获取到锁lock.lock();// 3. 执行业务逻辑(即使执行超过30秒,看门狗会自动续期)System.out.println("获取锁成功,执行库存扣减等业务...");Thread.sleep(40000); // 模拟耗时业务} catch (InterruptedException e) {e.printStackTrace();} finally {// 4. 释放锁:必须在finally中执行if (lock.isHeldByCurrentThread()) {lock.unlock();}}}
}Redis锁的优劣与适用场景
优势 | 劣势 | 适用场景 |
性能极高:Redis是内存数据库,加锁解锁耗时微秒级 | 主从复制有延迟:主节点锁未同步到从节点时主节点宕机,可能出现锁丢失 | 高并发场景:如秒杀、库存扣减、分布式限流等 |
实现简单,Redisson封装完善,开箱即用 | 需处理锁过期、看门狗续期等细节 | 对性能要求高,允许短暂“锁丢失”风险的场景(可通过Redis集群优化) |
2.3 方式三:基于ZooKeeper的分布式锁(最可靠,略重)
ZooKeeper是分布式协调服务,其“临时顺序节点”特性天然适合实现分布式锁。核心思路是“通过创建临时顺序节点竞争锁,最小序号节点获取锁,其他节点监听前序节点”。
核心原理
- 创建临时顺序节点:客户端在ZooKeeper的/locks节点下,为目标资源创建临时顺序节点,如/locks/product:stock:1001/lock-00000001;
- 竞争锁:获取/locks/product:stock:1001下的所有子节点,排序后若自己是最小序号节点,则获取锁成功;
- 监听前序节点:若不是最小节点,则监听前一个节点(如自己是lock-00000003,监听lock-00000002),前序节点释放锁时会触发通知;
- 释放锁:业务执行完成后,删除自己创建的临时节点(客户端崩溃时,临时节点会自动删除,避免死锁)。
实战实现:用Curator客户端(推荐)
ZooKeeper原生API实现锁较复杂,推荐使用Apache Curator客户端,它已封装好分布式锁的实现(InterProcessMutex)。
- 引入依赖:
<dependency><groupId>org.apache.curator</groupId><artifactId>curator-recipes</artifactId><version>5.5.0</version>
</dependency>- 实现分布式锁:
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import java.util.concurrent.TimeUnit;public class ZkLockDemo {private static final String LOCK_PATH = "/distributed/locks/";private CuratorFramework curatorFramework;
public ZkLockDemo(CuratorFramework curatorFramework) {this.curatorFramework = curatorFramework;
}public void doBusinessWithLock(String resource) {// 1. 创建分布式锁实例InterProcessMutex lock = new InterProcessMutex(curatorFramework, LOCK_PATH + resource);try {// 2. 获取锁:最多等待10秒,获取成功返回trueif (lock.acquire(10, TimeUnit.SECONDS)) {// 3. 执行业务逻辑System.out.println("获取锁成功,执行分布式任务...");Thread.sleep(10000);} else {throw new RuntimeException("获取锁失败,超时");}} catch (Exception e) {e.printStackTrace();} finally {// 4. 释放锁try {if (lock.isAcquiredInThisProcess()) {lock.release();}} catch (Exception e) {e.printStackTrace();}}
}}`
ZooKeeper锁的优劣与适用场景
优势 | 劣势 | 适用场景 |
可靠性高:临时节点天然支持崩溃自动释放锁,监听机制确保锁高效竞争 | 性能中等:ZooKeeper是CP系统,节点同步有开销,加锁解锁耗时毫秒级 | 对可靠性要求极高的场景:如分布式事务、分布式一致性校验 |
无锁过期问题:无需手动设置过期时间,避免业务超时导致的锁释放问题 | 部署复杂:需维护ZooKeeper集群,运维成本高 | 并发量适中,不追求极致性能,但要求绝对可靠的场景 |
三、分布式锁的3大核心使用场景
了解了实现方式后,还要明确哪些场景需要用分布式锁。以下三个场景是分布式系统中的高频需求:
3.1 分布式事务:保证跨节点操作的一致性
在分布式事务中,若多个节点需要操作同一资源,需用分布式锁保证“要么都执行,要么都不执行”。比如跨库转账:A库扣减金额和B库增加金额,需用锁锁定转账订单,避免重复转账。
3.2 资源共享:控制并发访问频率
当多个节点共享有限资源时,需用分布式锁控制访问频率。比如:
- 分布式限流:限制某接口的总并发请求数,用锁控制计数器的增减;
- 库存扣减:秒杀场景中,多个节点同时扣减同一商品库存,用锁保证库存不超卖。
3.3 任务调度:避免重复执行
分布式任务调度系统中,同一任务可能部署在多个节点,需用分布式锁保证“同一时间只有一个节点执行任务”。比如定时清理日志的任务,若多个节点同时执行,会造成资源浪费。
四、分布式锁的4大常见问题与解决方案
分布式锁的实现和使用中,很容易踩坑。以下四个问题是开发和面试中的高频考点,必须掌握:
4.1 死锁:客户端崩溃导致锁无法释放
问题原因:客户端获取锁后崩溃,未执行释放锁操作,导致锁永远被持有。
解决方案:
Redis锁:设置锁过期时间,配合看门狗机制;
ZooKeeper锁:使用临时节点,客户端崩溃后节点自动删除;
数据库锁:定时任务清理超时未释放的锁(根据create_time字段)。
4.2 锁竞争:高并发下锁等待时间过长
问题原因:高并发场景下,大量客户端等待同一把锁,导致响应延迟。
解决方案:
锁粒度拆分:将粗粒度锁拆分为细粒度锁,比如将“商品库存锁”拆分为“单品库存锁”;
非阻塞锁:使用tryLock方法,获取不到锁时直接返回,避免阻塞等待;
排队机制:结合ZooKeeper的顺序节点,实现公平锁,避免饥饿问题。
4.3 锁丢失:主从复制延迟导致的问题
问题原因:Redis主从架构中,主节点获取锁后未同步到从节点,主节点宕机,从节点升级为主节点,其他客户端可重新获取锁,导致锁丢失。
解决方案:
使用Redis集群的Redlock算法:向多个独立的Redis节点请求锁,只有超过半数节点获取成功,才认为锁获取成功;
优先使用ZooKeeper锁:ZooKeeper的CP特性确保主从节点数据一致,避免锁丢失。
4.4 误删锁:释放其他客户端的锁
问题原因:客户端A获取锁后执行超时,锁被自动释放,客户端B获取到锁;此时客户端A执行完业务,误删了客户端B的锁。
解决方案:
锁值校验:释放锁时校验锁值是否为自己获取锁时的唯一值(如Redis锁的UUID值);
原子操作:用Lua脚本实现“校验+释放”的原子操作,避免两步操作的间隙出现异常。
五、总结
分布式锁的核心价值是“在分布式环境下保证资源的互斥访问”,其实现方式没有绝对的优劣,只有“是否适配业务场景”。选择时需权衡三个维度:性能要求、可靠性要求、运维成本。
最后给出一个简单的选择指南:
- 高并发、高性能、能接受轻微锁丢失风险:Redis锁(Redisson);
- 高可靠、并发适中、能接受运维成本:ZooKeeper锁(Curator);
- 小型系统、快速落地、低并发:数据库锁。
