分布式锁的可靠性设计与保障
2021-05-213.9k 阅读
分布式锁的基本概念
在单体应用中,我们可以通过诸如 synchronized
关键字或者 ReentrantLock
等工具来实现锁机制,以保证在多线程环境下对共享资源的安全访问。然而,随着系统架构从单体向分布式演进,原来基于进程内的锁机制无法直接应用到分布式场景中。
分布式锁是一种跨进程、跨机器的锁机制,其目的是在分布式系统环境下,保证同一时间只有一个应用实例能够访问特定的共享资源,从而避免数据不一致和并发冲突等问题。例如,在电商系统中,库存扣减操作如果没有合理的锁机制,可能会出现超卖现象,这时分布式锁就可以发挥作用,确保库存扣减操作的原子性。
实现分布式锁的常见方式
基于数据库的分布式锁
- 原理 基于数据库的分布式锁实现主要利用数据库的唯一性约束。例如,在数据库中创建一张锁表,表中包含一个唯一索引字段。当一个应用实例想要获取锁时,向这张表插入一条记录,如果插入成功,说明获取锁成功;如果插入失败(由于唯一性约束),则说明锁已被其他实例获取。
- 代码示例(以MySQL和Java为例) 首先创建锁表:
CREATE TABLE `distributed_lock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`lock_key` varchar(255) NOT NULL,
`lock_value` varchar(255) NOT NULL,
`expire_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_lock_key` (`lock_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Java代码实现获取锁:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public class DatabaseLock {
private static final String URL = "jdbc:mysql://localhost:3306/your_database";
private static final String USER = "root";
private static final String PASSWORD = "password";
public static boolean tryLock(String lockKey, String lockValue, long expireTime) {
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = DriverManager.getConnection(URL, USER, PASSWORD);
String sql = "INSERT INTO distributed_lock (lock_key, lock_value, expire_time) VALUES (?,?, NOW() + INTERVAL? SECOND)";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, lockKey);
preparedStatement.setString(2, lockValue);
preparedStatement.setLong(3, expireTime);
int result = preparedStatement.executeUpdate();
return result == 1;
} catch (SQLException e) {
return false;
} finally {
if (preparedStatement != null) {
try {
preparedStatement.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
public static void unlock(String lockKey) {
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = DriverManager.getConnection(URL, USER, PASSWORD);
String sql = "DELETE FROM distributed_lock WHERE lock_key =?";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, lockKey);
preparedStatement.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
} finally {
if (preparedStatement != null) {
try {
preparedStatement.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
- 优缺点 优点:实现简单,对技术栈没有特殊要求,几乎所有应用都可以基于数据库来实现分布式锁。 缺点:性能瓶颈明显,因为数据库的写入操作性能相对较低,高并发场景下会成为系统性能的瓶颈。而且如果锁表所在的数据库出现故障,会导致整个分布式锁机制不可用。
基于缓存的分布式锁
- 原理
基于缓存的分布式锁通常利用缓存的原子性操作,如 Redis 的
SETNX
命令(SET if Not eXists)。当一个实例尝试获取锁时,使用SETNX
命令在 Redis 中设置一个特定的键值对,如果设置成功,说明获取锁成功;如果设置失败,说明锁已被其他实例获取。 - 代码示例(以Redis和Java为例,使用Jedis库)
import redis.clients.jedis.Jedis;
public class RedisLock {
private static final String LOCK_KEY = "distributed_lock_key";
private static final String LOCK_VALUE = "unique_value";
private static final int EXPIRE_TIME = 10; // 10秒
public static boolean tryLock(Jedis jedis) {
Long result = jedis.setnx(LOCK_KEY, LOCK_VALUE);
if (result == 1) {
jedis.expire(LOCK_KEY, EXPIRE_TIME);
return true;
}
return false;
}
public static void unlock(Jedis jedis) {
jedis.del(LOCK_KEY);
}
}
- 优缺点 优点:性能高,Redis 是基于内存的高性能缓存,原子性操作的性能非常好,适用于高并发场景。而且 Redis 支持集群部署,可以提高系统的可用性。 缺点:存在锁过期问题,如果业务逻辑执行时间超过了锁的过期时间,可能会导致其他实例获取到锁,从而引发并发问题。另外,如果 Redis 集群出现脑裂等异常情况,也可能导致锁的可靠性受到影响。
基于Zookeeper的分布式锁
- 原理 Zookeeper 是一个分布式协调服务框架,它的数据结构类似于文件系统的树形结构。基于 Zookeeper 的分布式锁实现主要利用其临时顺序节点的特性。当一个实例尝试获取锁时,在 Zookeeper 的特定目录下创建一个临时顺序节点。然后获取该目录下所有的子节点并排序,判断自己创建的节点是否是最小的,如果是,则获取锁成功;否则,监听比自己小的前一个节点的删除事件,当监听到事件时,再次判断自己是否是最小的节点,以决定是否获取锁。
- 代码示例(以Java和Curator库为例)
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
public class ZookeeperLock {
private static final String ZK_ADDRESS = "localhost:2181";
private static final String LOCK_PATH = "/distributed_lock";
public static void main(String[] args) {
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(ZK_ADDRESS)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
client.start();
InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
try {
lock.acquire();
// 执行业务逻辑
System.out.println("获取到锁,执行业务逻辑");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
lock.release();
System.out.println("释放锁");
} catch (Exception e) {
e.printStackTrace();
}
client.close();
}
}
}
- 优缺点 优点:可靠性高,Zookeeper 本身的设计保证了数据的强一致性和高可用性。而且 Zookeeper 可以很好地处理锁的超时和死锁问题,因为临时节点会在客户端会话结束时自动删除。 缺点:性能相对 Redis 缓存稍低,因为 Zookeeper 的写操作需要进行过半节点的同步,这会带来一定的延迟。另外,Zookeeper 的使用相对复杂,需要对其原理和 API 有深入的了解。
分布式锁的可靠性设计要点
锁的原子性
- 含义
锁的原子性确保在获取和释放锁的过程中,不会出现部分操作成功,部分操作失败的情况。例如,在基于 Redis 的分布式锁中,如果
SETNX
和EXPIRE
命令不是原子性执行的,可能会出现SETNX
成功后,还未执行EXPIRE
时程序崩溃,导致这个锁永远不会过期。 - 解决方案
在 Redis 中,从 Redis 2.6.12 版本开始,
SET key value [EX seconds] [PX milliseconds] [NX|XX]
命令可以实现原子性地设置键值对并设置过期时间。在使用时,可以直接使用这个命令来获取锁,确保原子性。
import redis.clients.jedis.Jedis;
public class RedisAtomicLock {
private static final String LOCK_KEY = "distributed_lock_key";
private static final String LOCK_VALUE = "unique_value";
private static final int EXPIRE_TIME = 10; // 10秒
public static boolean tryLock(Jedis jedis) {
String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", EXPIRE_TIME);
return "OK".equals(result);
}
public static void unlock(Jedis jedis) {
jedis.del(LOCK_KEY);
}
}
锁的过期时间设置
- 合理设置过期时间的重要性 如果过期时间设置过短,业务逻辑可能还未执行完锁就过期了,导致并发问题;如果设置过长,在持有锁的实例出现故障无法释放锁时,会造成长时间的资源占用,影响系统的可用性。
- 动态调整过期时间的策略 可以根据业务的平均执行时间来动态调整锁的过期时间。例如,记录每次业务逻辑执行的时间,通过滑动窗口算法等方式计算出平均执行时间,并在此基础上适当增加一定的缓冲时间作为锁的过期时间。另外,也可以在业务逻辑执行过程中,如果发现剩余时间不足以完成业务,可以尝试延长锁的过期时间。
import redis.clients.jedis.Jedis;
public class RedisDynamicLock {
private static final String LOCK_KEY = "distributed_lock_key";
private static final String LOCK_VALUE = "unique_value";
private static final int DEFAULT_EXPIRE_TIME = 10; // 10秒
private static final int EXTEND_TIME = 5; // 延长5秒
public static boolean tryLock(Jedis jedis) {
String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", DEFAULT_EXPIRE_TIME);
return "OK".equals(result);
}
public static boolean extendLock(Jedis jedis) {
Long result = jedis.expire(LOCK_KEY, DEFAULT_EXPIRE_TIME + EXTEND_TIME);
return result == 1;
}
public static void unlock(Jedis jedis) {
jedis.del(LOCK_KEY);
}
}
锁的高可用性
- 避免单点故障 无论是基于数据库、缓存还是 Zookeeper 的分布式锁,都要避免单点故障。对于基于数据库的锁,可采用主从复制、读写分离等架构来提高可用性;基于 Redis 的锁,可以采用 Redis 集群部署,确保在某个节点故障时,其他节点仍能提供锁服务;基于 Zookeeper 的锁,Zookeeper 本身就是一个分布式集群,通过合理的节点配置,可以保证高可用性。
- 故障恢复机制 当出现故障导致锁服务不可用时,要有相应的故障恢复机制。例如,在基于 Redis 的分布式锁中,如果某个节点故障,需要在故障恢复后,对之前的锁状态进行检查和恢复,避免出现锁丢失或重复获取锁的情况。可以通过在 Redis 中设置额外的元数据来记录锁的状态,在故障恢复时进行校验和修复。
锁的可重入性
- 含义 可重入性是指同一个线程(或实例)在持有锁的情况下,可以再次获取同一把锁而不会被阻塞。在分布式场景中,这一点同样重要,例如,一个服务可能会调用自身的其他方法,而这些方法可能也需要获取同一把分布式锁,如果锁不支持可重入,就会导致死锁。
- 实现方式 在基于 Redis 的分布式锁中,可以通过在锁的 value 中记录获取锁的实例标识和重入次数来实现可重入性。当一个实例获取锁时,如果发现锁的 value 是自己的标识,则增加重入次数;释放锁时,减少重入次数,当重入次数为 0 时,真正删除锁。
import redis.clients.jedis.Jedis;
public class RedisReentrantLock {
private static final String LOCK_KEY = "distributed_lock_key";
private static final String INSTANCE_ID = "instance_1";
private static final int DEFAULT_EXPIRE_TIME = 10; // 10秒
public static boolean tryLock(Jedis jedis) {
String lockValue = INSTANCE_ID + ":1";
String result = jedis.set(LOCK_KEY, lockValue, "NX", "EX", DEFAULT_EXPIRE_TIME);
if ("OK".equals(result)) {
return true;
}
String currentValue = jedis.get(LOCK_KEY);
if (currentValue != null && currentValue.startsWith(INSTANCE_ID)) {
int reentrantCount = Integer.parseInt(currentValue.split(":")[1]) + 1;
lockValue = INSTANCE_ID + ":" + reentrantCount;
jedis.set(LOCK_KEY, lockValue);
return true;
}
return false;
}
public static void unlock(Jedis jedis) {
String currentValue = jedis.get(LOCK_KEY);
if (currentValue != null && currentValue.startsWith(INSTANCE_ID)) {
int reentrantCount = Integer.parseInt(currentValue.split(":")[1]);
if (reentrantCount == 1) {
jedis.del(LOCK_KEY);
} else {
int newReentrantCount = reentrantCount - 1;
String newLockValue = INSTANCE_ID + ":" + newReentrantCount;
jedis.set(LOCK_KEY, newLockValue);
}
}
}
}
分布式锁的可靠性保障实践
基于 Redis 集群的分布式锁实践
- 集群搭建 使用 Redis Cluster 搭建 Redis 集群,通过配置多个节点,实现数据的分布式存储和高可用性。在搭建过程中,需要合理规划节点数量和数据分片,确保每个节点的负载均衡。
- 锁的实现与优化
在 Redis 集群环境下,获取锁和释放锁的操作需要考虑集群的特性。可以使用 Redis 集群的
SET
命令来获取锁,确保原子性。同时,为了提高锁的可靠性,可以在获取锁时设置一个随机的token
,在释放锁时验证token
,防止误释放其他实例的锁。
import redis.clients.jedis.*;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class RedisClusterLock {
private static final String LOCK_KEY = "distributed_lock_key";
private static final int DEFAULT_EXPIRE_TIME = 10; // 10秒
public static boolean tryLock(JedisCluster jedisCluster) {
String token = UUID.randomUUID().toString();
String result = jedisCluster.set(LOCK_KEY, token, "NX", "EX", DEFAULT_EXPIRE_TIME);
if ("OK".equals(result)) {
return true;
}
return false;
}
public static void unlock(JedisCluster jedisCluster, String token) {
String currentToken = jedisCluster.get(LOCK_KEY);
if (token.equals(currentToken)) {
jedisCluster.del(LOCK_KEY);
}
}
public static void main(String[] args) {
Set<HostAndPort> jedisClusterNodes = new HashSet<>();
jedisClusterNodes.add(new HostAndPort("127.0.0.1", 7000));
jedisClusterNodes.add(new HostAndPort("127.0.0.1", 7001));
jedisClusterNodes.add(new HostAndPort("127.0.0.1", 7002));
jedisClusterNodes.add(new HostAndPort("127.0.0.1", 7003));
jedisClusterNodes.add(new HostAndPort("127.0.0.1", 7004));
jedisClusterNodes.add(new HostAndPort("127.0.0.1", 7005));
JedisCluster jedisCluster = new JedisCluster(jedisClusterNodes);
if (tryLock(jedisCluster)) {
try {
// 执行业务逻辑
System.out.println("获取到锁,执行业务逻辑");
} finally {
unlock(jedisCluster, UUID.randomUUID().toString());
System.out.println("释放锁");
}
} else {
System.out.println("获取锁失败");
}
jedisCluster.close();
}
}
基于 Zookeeper 与 Redis 结合的分布式锁实践
- 结合的优势 Zookeeper 保证了锁的强一致性和可靠性,而 Redis 提供了高性能的读写操作。将两者结合,可以在保证锁的可靠性的同时,提高系统的性能。例如,使用 Zookeeper 来进行锁的竞争和管理,确保只有一个实例能获取到锁,而使用 Redis 来存储锁的相关元数据,如锁的过期时间、重入次数等,利用 Redis 的高性能读写来提高操作效率。
- 实现流程 当一个实例尝试获取锁时,首先在 Zookeeper 上创建临时顺序节点进行锁竞争。获取锁成功后,在 Redis 中设置锁的元数据,如过期时间和重入次数等。在释放锁时,先更新 Redis 中的元数据,再删除 Zookeeper 上的临时节点。
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import redis.clients.jedis.Jedis;
public class ZKAndRedisLock {
private static final String ZK_ADDRESS = "localhost:2181";
private static final String LOCK_PATH = "/distributed_lock";
private static final String REDIS_LOCK_KEY = "redis_distributed_lock_key";
private static final int DEFAULT_EXPIRE_TIME = 10; // 10秒
public static void main(String[] args) {
CuratorFramework zkClient = CuratorFrameworkFactory.builder()
.connectString(ZK_ADDRESS)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
zkClient.start();
InterProcessMutex lock = new InterProcessMutex(zkClient, LOCK_PATH);
Jedis jedis = new Jedis("localhost", 6379);
try {
lock.acquire();
jedis.setex(REDIS_LOCK_KEY, DEFAULT_EXPIRE_TIME, "locked");
// 执行业务逻辑
System.out.println("获取到锁,执行业务逻辑");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
jedis.del(REDIS_LOCK_KEY);
lock.release();
System.out.println("释放锁");
} catch (Exception e) {
e.printStackTrace();
}
zkClient.close();
jedis.close();
}
}
}
分布式锁的监控与异常处理
监控指标
- 锁的获取成功率 通过统计锁的获取成功次数与总尝试次数的比例,可以了解分布式锁的可用性。如果获取成功率过低,可能存在锁竞争过于激烈、锁服务故障等问题。
- 锁的持有时间 监控锁的持有时间,可以判断业务逻辑的执行时间是否合理,以及锁的过期时间设置是否恰当。如果锁的持有时间经常超过过期时间,需要调整过期时间或优化业务逻辑。
- 锁的竞争程度 统计同一时间内尝试获取锁的实例数量,可以了解锁的竞争程度。竞争程度过高可能需要考虑优化业务逻辑,减少锁的使用,或者采用更高效的分布式锁实现方式。
异常处理
- 获取锁失败的处理 当获取锁失败时,应用程序可以根据业务需求进行不同的处理。例如,对于一些非关键业务,可以直接返回失败结果;对于关键业务,可以采用重试机制,在一定时间间隔后再次尝试获取锁。重试次数和时间间隔需要根据实际情况进行合理设置,避免无限重试导致系统资源耗尽。
import redis.clients.jedis.Jedis;
public class RedisLockWithRetry {
private static final String LOCK_KEY = "distributed_lock_key";
private static final String LOCK_VALUE = "unique_value";
private static final int DEFAULT_EXPIRE_TIME = 10; // 10秒
private static final int MAX_RETRY = 3;
private static final int RETRY_INTERVAL = 1000; // 1秒
public static boolean tryLockWithRetry(Jedis jedis) {
for (int i = 0; i < MAX_RETRY; i++) {
String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", DEFAULT_EXPIRE_TIME);
if ("OK".equals(result)) {
return true;
}
try {
Thread.sleep(RETRY_INTERVAL);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return false;
}
public static void unlock(Jedis jedis) {
jedis.del(LOCK_KEY);
}
}
-
锁过期异常的处理 如果检测到锁过期异常(例如业务逻辑执行时间超过了锁的过期时间),可以采用延长锁的过期时间、回滚业务操作等方式进行处理。延长锁的过期时间可以通过调用锁服务的相关 API 来实现;回滚业务操作则需要根据业务的具体情况,在业务逻辑中实现相应的回滚机制。
-
锁服务故障的处理 当锁服务出现故障时,首先要记录故障日志,包括故障发生的时间、故障类型等信息。然后,应用程序可以尝试切换到备用的锁服务(如果有),或者暂停相关的业务操作,等待锁服务恢复。同时,通知运维人员及时处理故障,确保锁服务的可用性。
总结
分布式锁在分布式系统中起着至关重要的作用,它的可靠性直接影响到系统的数据一致性和稳定性。通过深入理解分布式锁的基本概念、常见实现方式以及可靠性设计要点,并结合实际的应用场景进行实践,同时加强对分布式锁的监控和异常处理,可以有效保障分布式锁的可靠性。在实际开发中,需要根据系统的性能、可用性、复杂度等多方面因素综合选择合适的分布式锁实现方案,并不断优化和完善,以满足分布式系统日益增长的需求。