为什么要使用分布式锁
在分布式系统中,多个服务实例或节点可能会同时访问和修改共享资源。如果没有适当的同步机制,就可能导致数据不一致、脏读、脏写等并发问题。分布式锁就是为了解决这些问题而引入的一种同步机制。
分布式锁的作用
-
保证数据一致性:在分布式系统中,多个服务实例可能会同时读取和修改同一个数据项。通过使用分布式锁,可以确保在任意时刻只有一个服务实例能够访问该数据项,从而避免了数据不一致的问题。
-
避免并发冲突:在并发环境下,多个线程或进程可能会同时尝试执行某项操作。如果没有适当的同步机制,就可能导致并发冲突,例如两个线程同时尝试修改同一个数据项。分布式锁能够确保在任意时刻只有一个线程或进程能够执行某项操作,从而避免了并发冲突。
-
提高系统可伸缩性:随着业务的发展,系统可能需要扩展更多的服务实例来处理更多的请求。通过使用分布式锁,可以将共享资源的访问权限分散到多个服务实例上,从而提高了系统的可伸缩性。
分布式锁的实现方式
分布式锁的实现方式有很多种,例如基于数据库、基于缓存(如Redis)、基于ZooKeeper等。其中,基于Redis的分布式锁因为Redis的高性能和易扩展性而广受欢迎。
Redlock算法
Redlock算法是一种分布式锁算法,由Redis之父Salvatore Sanfilippo设计,旨在解决在分布式系统中实现可靠的锁机制时可能遇到的单点故障问题。该算法通过在多个独立的Redis节点上尝试获取锁来提高系统的可用性和容错性。Redlock算法的基本思想如下:
-
获取多个锁:客户端会尝试在多个Redis节点上获取锁。只有当客户端在大多数(N/2+1,其中N是Redis节点的总数)节点上成功获取锁时,才认为获取了分布式锁。
-
设置锁的自动过期时间:为了防止某个客户端在持有锁的过程中崩溃而导致死锁,Redlock算法会要求客户端在获取锁时设置一个自动过期时间。当这个时间到达后,锁会自动释放。
-
顺序请求锁:为了降低网络延迟和时钟误差对锁获取的影响,Redlock算法要求客户端按照相同的顺序请求所有Redis节点的锁。
-
释放锁:当客户端完成操作或发生异常时,需要释放之前获取的锁。由于Redlock算法允许多个Redis节点同时持有锁,因此客户端需要遍历所有Redis节点并释放它们持有的锁。
Redlock算法的优势
-
提高可用性:通过在多个Redis节点上尝试获取锁,Redlock算法能够在某些节点出现故障时仍然提供锁服务,从而提高了系统的可用性。
-
容错性:Redlock算法允许在少数Redis节点出现故障时仍然能够正确地获取和释放锁,从而提高了系统的容错性。
-
可扩展性:Redlock算法可以很容易地扩展到更多的Redis节点上,以支持更大的并发量和更高的系统吞吐量。
实现算法
在 Redlock 算法的实际实现中,我们通常会使用 Lua 脚本来确保在 Redis 中设置锁和释放锁操作的原子性。由于 Lua 脚本可以在 Redis 服务器上作为一个单独的事务执行,这确保了即使在网络分区或其他并发条件下,锁的行为也是一致的。
import * as Redis from 'ioredis';
interface RedlockOptions {
driftFactor?: number;
retryCount?: number;
retryDelay?: number;
retryJitter?: number;
}
class Redlock {
private clients: Redis.Redis[];
private options: RedlockOptions;
private acquireScript: string;
private extendScript: string;
private releaseScript: string;
constructor(clients: Redis.Redis[], options: RedlockOptions = {}) {
this.clients = clients;
this.options = {
// driftFactor:时钟漂移因子
// 分布式系统中各个节点的时钟可能存在微小的差异(时钟漂移),在判断锁是否过期时,会加上锁的 TTL(Time To Live)的百分比作为缓冲时间。默认值为 0.01,表示额外加上原有效期的 1%。
driftFactor: options.driftFactor ?? 0.01,
// retryCount:重试次数
// 如果在尝试获取锁时失败(例如锁已被其他客户端持有),客户端会尝试重新获取锁。这个参数指定了重试的次数。默认值为 3,表示总共会尝试 3 次。
retryCount: options.retryCount ?? 3,
// retryDelay:重试延迟
// 在每次尝试获取锁失败后,客户端会等待一段时间再尝试。这个参数指定了等待的时间(通常以毫秒为单位)。默认值为 200 毫秒。
retryDelay: options.retryDelay ?? 200,
// retryJitter:重试抖动
// 为了避免所有的客户端都在同一时间重新尝试获取锁(可能产生“惊群效应”),可以在每次重试时引入一些随机性(抖动)。这个参数指定了随机性的范围(通常以毫秒为单位)。默认值为 200 毫秒,表示在每次重试时,实际的等待时间会在 retryDelay 的基础上加上或减去一个随机数(范围在 0 到 200 毫秒之间)。
retryJitter: options.retryJitter ?? 200,
};
this.acquireScript = `
for i, key in ipairs(KEYS) do
if redis.call("exists", key) == 1 then
return 0
end
end
for i, key in ipairs(KEYS) do
redis.call("set", key, ARGV[1], "PX", ARGV[2], "NX")
end
return #KEYS
`;
this.extendScript = `
for i, key in ipairs(KEYS) do
if redis.call("get", key) ~= ARGV[1] or redis.call("ttl", key) < 0 then
return 0
end
end
for i, key in ipairs(KEYS) do
redis.call("expire", key, ARGV[2])
end
return #KEYS
`;
this.releaseScript = `
local count = 0
for i, key in ipairs(KEYS) do
if redis.call("get", key) == ARGV[1] then
redis.call("del", key)
count = count + 1
end
end
return count
`;
}
private async acquireLock(client: Redis.Redis, resource: string, ttl: number, value: string): Promise<number> {
return client.eval(this.acquireScript, 1, resource, value, ttl.toString());
}
private async releaseLock(client: Redis.Redis, resource: string, value: string): Promise<number> {
return client.eval(this.releaseScript, 1, resource, value);
}
async extend(client: Redis.Redis, resource: string, ttl: number, value: string): Promise<number> {
return client.eval(this.extendScript, 1, resource, value, ttl.toString());
}
async lock(resource: string, ttl: number): Promise<string | null> {
const drift = Math.ceil(ttl * this.options.driftFactor);
let validLocksCount = 0;
const locks: { client: Redis.Redis; value: string }[] = [];
const startTime = Date.now();
for (let attempt = 0; attempt < this.options.retryCount; attempt++) {
const validThisTime = new Set<Redis.Redis>();
for (const client of this.clients) {
const lockValue = `${startTime}:${Math.random().toString(36).slice(2)}`; // 创建一个唯一的锁值
const ttlMillis = Math.max(
ttl - Math.floor((Date.now() - startTime) / 1000),
drift,
);
try {
const result = await this.acquireLock(
client,
resource,
ttlMillis,
lockValue,
);
if (result === 1) {
// 成功获取锁
validThisTime.add(client);
locks.push({ client, value: lockValue });
}
// 如果在所有实例上都获取了锁,则退出循环
if (validThisTime.size === this.clients.length) {
validLocksCount = this.clients.length;
break;
}
} catch (err) {
// 忽略错误,继续尝试下一个实例
}
}
// 检查是否在所有实例上都成功获取了锁
if (validLocksCount === this.clients.length) {
// 成功获取锁
return lockValue;
}
// 等待重试延迟
await new Promise((resolve) =>
setTimeout(resolve, this.options.retryDelay),
);
// 清理本次尝试中获取的锁
for (const { client } of locks) {
if (!validThisTime.has(client)) {
await this.releaseLock(
client,
resource,
locks.find((l) => l.client === client)?.value || '',
);
}
}
// 重置锁列表,为下一次尝试做准备
locks.length = 0;
// 检查是否超出了总的 TTL 时间
if (Date.now() - startTime >= ttl) {
return null; // 超出 TTL 时间,返回 null
}
}
// 如果在重试次数内都没有在所有实例上获取到锁,则返回 null
return null;
}
async unlock(resource: string, lockValue: string): Promise<number> {
let totalReleased = 0;
// 遍历所有客户端并尝试释放锁
for (const client of this.clients) {
try {
// 尝试释放锁并获取释放的锁的数量
const released = await this.releaseLock(client, resource, lockValue);
totalReleased += released; // 累加释放的锁的数量
// 由于我们只有一个锁键对应于 resource,所以一旦一个客户端成功释放了锁,我们就没有必要继续在其他客户端上尝试
if (released > 0) {
break; // 停止遍历,因为已经有一个客户端成功释放了锁
}
} catch (error) {
// 在这里处理错误,比如记录日志或抛出异常
console.error(`Failed to release lock for resource ${resource} on client`, error);
}
}
// 返回成功释放的锁的数量
return totalReleased;
}
async tryLock(redlock: Redlock, resources: string[], ttl: number): Promise<string[] | null> {
const lockPromises = resources.map(async (resource) => {
try {
const lockValue = await this.lock(resource, ttl);
if (lockValue) {
return { resource, lockValue };
}
throw new Error('Failed to lock resource');
} catch (error) {
// 如果一个资源锁定失败,则抛出错误,以便外部可以处理
throw error;
}
});
// 等待所有资源锁定
const lockedResources = await Promise.all(lockPromises);
return lockedResources.map(({ resource, lockValue }) => lockValue);
}
async using(resources: string[], ttl: number, callback: (signal: { aborted: boolean, error?: Error }) => Promise<void>): Promise<void> {
let lockedResources: string[] | null = null;
try {
// 尝试锁定所有资源
lockedResources = await this.tryLock(redlock, resources, ttl);
if (!lockedResources) {
throw new Error('Failed to lock all resources');
}
// 创建一个超时Promise
const timeoutPromise = new Promise((_, reject) => {
const timer = setTimeout(() => {
clearTimeout(timer);
reject(new Error('Operation timed out'));
}, ttl);
});
// 使用Promise.race来监听回调执行或超时
await Promise.race([
callback({ aborted: false }),
timeoutPromise
]);
// 如果不是由于超时完成的,则忽略callback的结果
} catch (error) {
// 如果在try块中抛出错误或超时,则进入这里
if (lockedResources) {
// 解锁已锁定的资源
for (let i = 0; i < resources.length; i++) {
if (lockedResources[i]) {
await this.unlock(resources[i], lockedResources[i]);
}
}
}
// 重新抛出错误
throw error;
} finally {
// 确保在finally块中不会再次抛出错误
// 因为错误已经在catch块中处理过了
}
}
使用示例
(async () => {
const redlock = new Redlock();
const resources = ['resource1', 'resource2'];
const ttl = 1000; // 锁定超时时间
// 锁定资源
await redlock.using(resources, ttl, async (signal) => {
// 处理业务
await processData(signal);
});
})();
总结
分布式锁在分布式系统中扮演着重要的角色,它能够确保数据一致性、避免并发冲突并提高系统可伸缩性。而Redlock算法作为一种基于Redis的分布式锁实现方式,具有高可用性、容错性和可扩展性等优势,因此在实际应用中得到了广泛的应用。