为什么使用分布式锁?
在单一机器的环境中,当多个线程可以同时修改某个共享变量时,可能会产生线程安全性问题。这些问题可以通过 Java 提供的 volatile
、ReentrantLock
、synchronized
以及 concurrent
并发包中的线程安全类等机制来解决。
然而,在分布式系统中,当需要跨不同机器的多个进程确保线程安全性时,这些机制就不再适用,因为它们只能保证在一个 JVM 实例内的多线程访问共享资源时的线程安全性。在这种情况下,就需要使用分布式锁来确保整个集群中同一方法或资源在同一时间只能被一个进程执行。
分布式锁应具备的条件
在探讨分布式锁的实现方式之前,我们需要了解分布式锁应当具备的条件:
- 互斥性:在任何时刻,只有一个客户端能够持有锁。
- 无死锁:具备锁失效机制,即使持有锁的客户端崩溃未主动释放锁,也要确保其他客户端可以获取锁。
- 不可误解锁:加锁和解锁必须是同一个客户端,客户端 A 不能解锁客户端 B 持有的锁。
- 高性能和高可用:能够高效地获取和释放锁。
- 可重入性:支持同一个客户端多次获取锁。
- 非阻塞性:未获取到锁时直接返回失败,而非阻塞等待。
分布式锁的实现方式
分布式锁可以通过以下三种方式实现:
- 基于数据库实现
- 基于缓存(如 Redis)实现
- 基于 Zookeeper 实现
基于数据库的实现方式
-
悲观锁:创建一张锁表,通过操作该表中的数据来实现加锁和解锁。要锁住某个方法或资源时,就向该表插入一条记录,表中设置方法名为唯一键,这样多个请求同时提交数据库时,只有一个操作可以成功,成功操作的线程获得锁;释放锁时删除这条记录。
-
乐观锁:每次更新操作都认为不会发生并发冲突,只有在更新失败时才会重试。例如,减少余额的操作可以使用此方案。具体实现是在表中增加一个版本号字段,每次更新时该版本号自增,更新余额时带上旧版本号作为条件,如果版本号匹配则更新,否则表示有其他并发操作已发生,需要重试。
基于 Redis 的实现方式
简单实现
Redis 2.6.12 及以后的版本中,可以使用 SETNX
命令加上过期时间来实现分布式锁:
SET lockKey value NX PX expire-time
加锁逻辑:
- 使用
SETNX
尝试获取锁,如果已有锁存在,则稍后再重试,以确保只有一个客户端能持有锁。 - 锁值设置为请求 ID(可以是 IP 地址加上线程名称),以便在解锁时验证请求者。
- 使用
EXPIRE
给锁设置一个过期时间,以防异常导致无法释放锁。
解锁逻辑:
- 获取锁对应的值,检查是否与请求 ID 相匹配,若匹配则删除锁。
- 使用 Lua 脚本进行原子操作以确保线程安全。
示例代码:
public class RedisTest {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_EXPIRE_TIME = "PX";
@Autowired
private JedisPool jedisPool;
public boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
Jedis jedis = jedisPool.getResource();
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
public boolean releaseDistributedLock(String lockKey, String requestId) {
Jedis jedis = jedisPool.getResource();
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if ("1".equals(result.toString())) {
return true;
}
return false;
}
}
RedLock
上述方案适用于单机 Redis 的分布式锁,但在 Redis 集群环境中,需要考虑主从切换的问题。为了解决这个问题,Redis 作者提出了更高级的分布式锁算法——RedLock。
RedLock 的核心思想是部署多个相互独立的 Redis Master 节点,并在这些节点上使用相同的加锁和解锁方法。客户端需要在大多数节点上成功获取锁,并且获取锁的时间要小于锁的有效期,才能认为获取锁成功。
基于 Zookeeper 的实现方式
ZooKeeper 是一个为分布式应用提供一致性服务的开源组件。基于 ZooKeeper 实现分布式锁的基本步骤包括:
- 创建一个目录(例如
mylock
)。 - 当客户端想要获取锁时,在该目录下创建一个临时顺序节点。
- 客户端获取该目录下的所有子节点,并确定自身是否是最小编号的节点,若是,则获得锁。
- 若未获得锁,则监听前一个节点的变化。
- 当持有锁的客户端完成操作并删除其节点时,下一个节点获得锁。
三种实现方式的比较
数据库分布式锁实现
- 优点:简单易用,无需引入额外中间件。
- 缺点:不适合高并发场景,数据库操作性能较低。
Redis 分布式锁实现
- 优点:性能好,适用于高并发场景,有较好的框架支持。
- 缺点:过期时间难以精确控制,需考虑锁被其他线程误删的情况。
Zookeeper 分布式锁实现
- 优点:具备良好的性能和可靠性,有成熟的框架支持。
- 缺点:相对于 Redis 实现来说性能略低,实现较为复杂。
总结
- 从性能角度来看:Redis > Zookeeper > 数据库。
- 从实现难度来看:数据库 > Redis > Zookeeper。
- 从可靠性和成熟度来看:Zookeeper > Redis > 数据库。