3分钟学会分布式锁

172 阅读19分钟

摘要

分布式锁是分布式系统中用于防止多进程或多线程访问同一共享资源的并发控制机制。分布式锁保证在任意时刻只有一个客户端能够访问某个资源,避免了资源竞争和数据不一致等问题。在实现分布式系统时,理解并正确使用分布式锁是确保系统稳定性和数据一致性的关键。

本文将首先介绍分布式锁的基本实现方法,然后详细讲解分布式锁的关键特性:锁自动续期锁只能由持有者释放超时自动释放可重入性,并提供相应的实现示例。

什么是分布式锁?

分布式锁是一种机制,确保在分布式系统中,多个进程或线程访问共享资源时,只能有一个进程或线程在某一时刻持有锁并操作该资源。分布式锁是分布式系统中解决并发问题、保证数据一致性的常用手段。

应用场景

  1. 高并发请求下的资源共享控制:多个服务实例并发访问数据库等共享资源时,分布式锁能确保每次只有一个实例能访问共享资源。
  2. 任务调度与管理:分布式系统中多个节点需要执行定时任务时,分布式锁可以防止重复执行。
  3. 防止重复操作:如防止多个服务节点重复执行某个业务操作,比如重复发短信或邮件。
  4. 数据一致性保障:多个服务节点同时修改某个共享数据时,分布式锁确保数据的一致性。

分布式锁的核心要点

  1. 锁可以自动续期: 锁的有效期通常是有限的,但如果锁的持有者没有在规定时间内释放锁,可以通过自动续期机制延长锁的有效期,防止锁提前过期,导致其他节点误以为锁已被释放。

  2. 锁只能由持有者释放锁: 锁的释放权限必须归持有锁的客户端,其他客户端不能随意释放锁。这样可以确保锁的管理不被其他节点干扰。

  3. 超时自动释放锁: 为了防止死锁的发生,分布式锁一般会设置超时时间。如果持锁节点在规定时间内未释放锁,锁会自动释放,其他节点才能获取锁,从而避免系统的长时间停滞。

  4. 可重入性: 有些分布式锁支持可重入性,即一个持锁线程可以多次获取锁,并且每次释放锁时,只有当所有的获取锁操作都完成时,锁才会被完全释放。

分布式锁的实现: python + redis 实现

在分布式系统中实现分布式锁,常用的工具是 Redis。Redis 提供了 SETNXEXPIRESET(原子操作)等命令,可以帮助实现分布式锁。

方案1: setnx + expire

import redis
import time

r = redis.StrictRedis(host='localhost', port=6379, db=0)

def acquire_lock(lock_name, timeout=10):
    # 如果锁不存在,setnx 会返回1,否则返回0
    if r.setnx(lock_name, "locked"):
        r.expire(lock_name, timeout)
        return True
    return False

def release_lock(lock_name):
    r.delete(lock_name)

lock_name = "my_lock"
if acquire_lock(lock_name):
    try:
        # 执行任务
        pass
    finally:
        release_lock(lock_name)

问题: 此方案的缺点是,setnxexpire 操作不是原子性的。如果在执行 setnx 成功之后,过了很长时间才执行 expire,可能会导致锁的过期时间设置不准确,或者如果网络不稳定,有可能出现竞争条件。

方案2: set(ex=)(推荐)

def acquire_lock_v2(lock_name, timeout=10):
    # 使用set命令,同时设置超时时间,保证原子性
    return r.set(lock_name, "locked", ex=timeout, nx=True)

优点: 该方案使用了 Redis 的 set 命令,并且通过 nx=True 确保了键值设置的原子性。ex 参数直接设置锁的过期时间,避免了多个操作的不一致性问题。

方案3: 使用 Lua 脚本

def acquire_lock_v3(lock_name, timeout=10):
    # 使用Lua脚本,确保原子性
    script = """
    if redis.call("exists", KEYS[1]) == 0 then
        redis.call("setex", KEYS[1], ARGV[1], "locked")
        return 1
    else
        return 0
    end
    """
    return r.eval(script, 1, lock_name, timeout)

优点: Lua 脚本是原子执行的,可以避免 setnx + expireset 操作的时序问题。通过脚本确保锁的获取和设置超时时间是一个原子操作,可以有效防止竞争条件。

分布式锁的关键问题:自动续期、锁释放权限、超时自动释放、可重入性

接下来,我们将详细讲解分布式锁的四个关键特性。

1. 锁可以自动续期

概念

在分布式环境中,锁的有效期通常是有限的。但某些任务的执行时间可能超过锁的过期时间。为了避免锁在任务执行过程中被意外释放,分布式锁需要支持自动续期的功能。当任务执行时间过长时,系统可以自动延长锁的过期时间,确保锁在任务期间持续有效。

实现

通常,续期操作由一个独立的线程或定时任务来实现。该线程会定期检查锁的有效期,并在锁即将过期时,自动延长锁的过期时间。直到任务完成并释放锁。

示例代码:锁续期

import redis
import time
import threading

r = redis.StrictRedis(host='localhost', port=6379, db=0)

def acquire_lock_with_renewal(lock_name, timeout=10, renewal_interval=5):
    """获取分布式锁并定期续期"""
    if r.set(lock_name, "locked", ex=timeout, nx=True):
        print("Lock acquired.")
        # 启动续期线程
        def renew_lock():
            while True:
                time.sleep(renewal_interval)
                if r.exists(lock_name):
                    r.expire(lock_name, timeout)
                    print(f"Lock renewed, expires in {timeout} seconds.")
                else:
                    break

        renewal_thread = threading.Thread(target=renew_lock)
        renewal_thread.start()
        return True
    return False

def release_lock(lock_name):
    """释放锁"""
    r.delete(lock_name)
    print("Lock released.")

解释

  • acquire_lock_with_renewal:获取锁并启动一个独立线程定期续期锁。
  • renew_lock:每隔 renewal_interval 秒延长锁的过期时间,直到任务完成。

2. 锁只能由持有者释放

概念

分布式锁的释放操作必须由持有锁的客户端来执行,其他客户端不能随意释放锁。这样可以避免错误地释放锁,导致其他客户端无法获取锁,确保系统的稳定性。

具体来说,我们需要保证只有持有锁的客户端才能释放锁,这可以通过在 Redis 中保存一个唯一的 lock_value 来实现。只有当客户端提供的 lock_value 与 Redis 中存储的值匹配时,才允许释放锁

实现方案

  1. 生成唯一的 UUID:客户端第一次尝试获取锁时,生成一个全局唯一的 UUID,并将其保存在一个全局变量中。这个 UUID 将作为客户端的唯一标识符。

  2. 加锁时使用全局 UUID:每次获取锁时,都将这个唯一的 UUID 作为 lock_value 存储在 Redis 中。

  3. 释放锁时检查 UUID:释放锁时,客户端会检查 Redis 中存储的 lock_value 是否与它自己的全局 UUID 匹配,只有匹配时才允许释放锁。

示例代码:使用全局 UUID 来管理锁

import redis
import uuid

# 创建 Redis 连接
r = redis.StrictRedis(host='localhost', port=6379, db=0)

def acquire_lock(lock_name, timeout=10):
    """获取分布式锁,返回锁的标识 (lock_value)"""
    # 生成唯一的 lock_value(客户端标识)
    lock_value = str(uuid.uuid4())

    # 使用 setnx 来确保只有一个客户端可以成功获取锁
    if r.set(lock_name, lock_value, ex=timeout, nx=True):
        print(f"Lock acquired by {lock_value}")
        return lock_value
    else:
        print("Failed to acquire lock.")
        return None

def release_lock(lock_name, lock_value):
    """释放锁,确保只有锁的持有者能够释放锁"""
    current_lock_value = r.get(lock_name)
    
    if current_lock_value and current_lock_value.decode() == lock_value:
        # 如果当前锁的值和提供的 lock_value 匹配,则释放锁
        r.delete(lock_name)
        print(f"Lock released by {lock_value}")
    else:
        # 如果 lock_value 不匹配,则不能释放锁
        print(f"Cannot release lock: {lock_value} is not the holder.")

# 示例使用
lock_name = "my_lock"

# 客户端 1 尝试获取锁
lock_value1 = acquire_lock(lock_name)

if lock_value1:
    try:
        # 客户端 1 执行任务
        print("Executing task for client 1...")
    finally:
        # 客户端 1 尝试释放锁
        release_lock(lock_name, lock_value1)

# 客户端 2 尝试获取锁
lock_value2 = acquire_lock(lock_name)

if lock_value2:
    try:
        # 客户端 2 执行任务
        print("Executing task for client 2...")
    finally:
        # 客户端 2 尝试释放锁
        release_lock(lock_name, lock_value2)


代码解释

  1. client_lock_value:全局变量,用于存储客户端在第一次获取锁时生成的唯一 UUID。此值会在第一次加锁时设置,并且在后续的加锁和释放锁操作中一直使用这个值,直到程序结束。

  2. acquire_reentrant_lock():在第一次获取锁时,我们生成一个新的 UUID 并将其赋值给 client_lock_value。此后每次获取锁时,都会将 client_lock_value 作为 lock_value 存储在 Redis 中。

  3. release_reentrant_lock():在释放锁时,我们检查 Redis 中的 lock_value 是否与客户端的 client_lock_value 相同。如果相同,则表示当前客户端是锁的持有者,可以释放锁;否则,拒绝释放。

优点

  1. 简单易实现:使用全局变量保存 UUID 的方法非常简单,适合单一客户端/进程的情况。

  2. 无需依赖额外信息:无需依赖机器的 IP 地址或其他系统信息,仅使用 UUID 来标识客户端,确保唯一性。

注意事项

  • 全局变量的作用域client_lock_value 作为全局变量,能够确保在同一个程序运行周期内,不会发生 UUID 的变化。然而,这种方法的缺点是无法在多个程序或服务实例之间共享该 UUID。如果需要跨进程或跨机器管理锁,那么这种方法就不适用了。

  • 程序重启问题:如果程序重启,client_lock_value 会丢失,无法继续保持原来的 UUID。为了解决这个问题,可以将 client_lock_value 持久化到本地文件或数据库中,但这增加了实现的复杂度。

总结

通过使用全局变量来保存客户端第一次生成的 UUID,我们可以简单地管理分布式锁的获取和释放,确保每个客户端都能唯一地标识自己,并正确释放自己持有的锁。这种方式适合单一客户端的场景,且易于实现。如果需要更高可扩展性或分布式环境的支持,可能需要考虑其他标识客户端的方式(如基于机器 IP 或使用全局唯一的客户端 ID)。

3. 超时自动释放锁

概念

分布式锁通常设置过期时间以防止死锁。如果持锁客户端在规定的时间内没有释放锁,锁会自动释放,允许其他客户端获取锁。这可以防止因程序异常、死循环等问题导致锁一直无法释放,造成系统无法正常工作。

实现

在 Redis 中,可以使用 SET 命令的 EX 参数来设置锁的过期时间,超时后 Redis 会自动删除锁,从而避免死锁现象。

示例代码:锁超时自动释放

def acquire_lock_with_timeout(lock_name, timeout=10):
    """使用 Redis 设置带有超时的分布式锁"""
    if r.set(lock_name, "locked", nx=True, ex=timeout):
        print("Lock acquired.")
        return True
    return False

解释

  • acquire_lock_with_timeout:通过 SET 命令设置锁并指定过期时间,超时后 Redis 会自动释放锁。

4. 可重入性

概念

可重入性是指同一线程或客户端可以多次获取同一把锁而不会被阻塞。如果一个线程已经持有锁,再次请求该锁时不应被阻塞,而应允许重入。支持可重入性的分布式锁避免了在复杂任务中因多次请求锁而导致的死锁或阻塞。

实现

在一个真正的可重入锁实现中,我们不仅需要知道锁是否被同一客户端持有,还需要知道该客户端已经获取了多少次锁,以便它在释放锁时可以正确地处理。

  • 计数:同一个客户端可以多次请求锁而不被阻塞,每次获取锁时计数加1,释放锁时计数减1,直到计数为0时才会真正释放锁。
  • 确保正确释放:只有当锁的计数值为0时,才真正释放锁,否则就只是减少计数,让客户端继续持有锁。

因此,重入锁不仅需要保存客户端标识(client_lock_value),还需要对每次获取锁和释放锁进行计数。

解决方案

  1. 计数器:我们需要在 Redis 中为每个客户端的锁维护一个计数器(counter),每次成功获取锁时增加计数器,释放锁时减少计数器。当计数器为0时,才真正释放锁。

  2. 获取锁时:如果客户端已经持有锁,则简单地增加计数器;如果是第一次获取锁,则在 Redis 中设置锁并将计数器初始化为1。

  3. 释放锁时:检查计数器。如果计数器大于1,减少计数器;如果计数器为1,删除锁并清除计数器。

示例代码:可重入锁实现

import redis
import uuid

# 创建 Redis 连接
r = redis.StrictRedis(host='localhost', port=6379, db=0)

# 全局变量,用于存储客户端第一次加锁时生成的 UUID
client_lock_value = None
lock_counter_key = None

def acquire_reentrant_lock(lock_name, timeout=10):
    """获取可重入分布式锁"""
    global client_lock_value, lock_counter_key
    
    # 如果是第一次加锁,则生成一个新的 UUID
    if client_lock_value is None:
        client_lock_value = str(uuid.uuid4())  # 生成全局唯一标识符
        lock_counter_key = f"{lock_name}_counter"  # 锁的计数器键名
        print(f"First time acquiring lock, generated client ID: {client_lock_value}")
    
    # 检查是否已经获得锁
    current_value = r.get(lock_name)

    if current_value is None:
        # 锁为空,第一次加锁
        if r.set(lock_name, client_lock_value, ex=timeout, nx=True):
            r.set(lock_counter_key, 1)  # 初始化计数器为 1
            print(f"Lock acquired with client ID: {client_lock_value}")
            return client_lock_value
    else:
        # 锁已经被占用,检查是否是当前客户端
        if current_value.decode() == client_lock_value:
            # 同一客户端重入锁
            r.incr(lock_counter_key)  # 增加计数器
            print(f"Re-entering lock with client ID: {client_lock_value}")
            return client_lock_value
    return None

def release_reentrant_lock(lock_name, client_lock_value):
    """释放可重入锁"""
    current_value = r.get(lock_name)
    
    if current_value and current_value.decode() == client_lock_value:
        # 获取计数器
        counter = int(r.get(lock_counter_key) or 0)
        
        if counter > 1:
            # 计数器大于1,只减少计数
            r.decr(lock_counter_key)
            print(f"Lock re-entered, decremented counter: {counter - 1}")
        else:
            # 计数器为1,删除锁和计数器,彻底释放锁
            r.delete(lock_name)
            r.delete(lock_counter_key)
            print(f"Lock released by client ID: {client_lock_value}")
    else:
        print("Lock was not acquired by this client, cannot release.")

# 示例使用
lock_name = "my_lock"
client_lock_value = acquire_reentrant_lock(lock_name)

if client_lock_value:
    try:
        # 执行任务
        print("Executing task...")
        
        # 模拟多次获取锁,展示可重入性
        client_lock_value = acquire_reentrant_lock(lock_name)
        if client_lock_value:
            print(f"Successfully re-entered the lock with client ID: {client_lock_value}")
        
        # 执行一些任务
        print("Executing another task...")
    finally:
        release_reentrant_lock(lock_name, client_lock_value)

代码讲解

  1. client_lock_value:用于保存客户端在第一次获取锁时生成的唯一标识符(UUID)。这个值在整个锁的生命周期内保持不变。

  2. lock_counter_key:用于存储锁的计数器。每次客户端成功获取锁时,计数器会增加,释放锁时,计数器会减少。

  3. acquire_reentrant_lock()

    • 第一次获取锁时,设置 Redis 中的锁键 (lock_name) 和计数器键 (lock_counter_key),并将计数器初始化为 1。
    • 如果锁已经被当前客户端持有,则增加计数器,表示重入锁。
    • 如果锁没有被占用,则将锁设置为当前客户端并初始化计数器。
  4. release_reentrant_lock()

    • 在释放锁时,检查计数器的值。如果计数器大于 1,表示客户端还需要持有锁一段时间,只减少计数器,不删除锁。如果计数器为 1,表示客户端已经不再需要锁,删除锁并清除计数器。

示例输出

First time acquiring lock, generated client ID: 123e4567-e89b-12d3-a456-426614174000
Lock acquired with client ID: 123e4567-e89b-12d3-a456-426614174000
Executing task...
Re-entering lock with client ID: 123e4567-e89b-12d3-a456-426614174000
Successfully re-entered the lock with client ID: 123e4567-e89b-12d3-a456-426614174000
Executing another task...
Lock re-entered, decremented counter: 1
Lock released by client ID: 123e4567-e89b-12d3-a456-426614174000

解释

  1. 第一次加锁:客户端成功获取锁,并初始化计数器为 1。
  2. 重入锁:客户端再次请求锁,发现已经持有锁,因此增加计数器。
  3. 释放锁:当客户端释放锁时,计数器减到 0,才会真正删除锁和计数器,释放锁资源。

总结

通过在 Redis 中增加计数器,我们实现了可重入性,允许客户端多次获取锁而不被阻塞。只有在所有的锁操作完成后(计数器为 0 时),才会真正释放锁。这个方案更加符合实际生产环境中对于分布式锁的要求,保证了锁的可靠性和可管理性。

下面给一个完整的搭配lua脚本实现的python分布式锁类

import redis
import uuid
import json
import time
from typing import Optional

# --- Redis 配置 ---
# 请根据你的实际环境修改
REDIS_CONFIG = {
    'host': 'localhost',
    'port': 6379,
    'db': 0,
    # 'password': 'your_password',
}

class ReentrantDistributedLock:
    """
    一个基于 Redis 实现的可重入分布式锁。
    特性:
    1. 互斥性:同一时刻只有一个客户端能持有锁。
    2. 安全性:锁只能由持有者释放。
    3. 防死锁:锁带有过期时间。
    4. 可重入性:同一客户端可多次获取锁。
    """
    def __init__(self, redis_client: redis.Redis, lock_key: str, lock_timeout: int = 30):
        """
        初始化锁。
        :param redis_client: Redis 客户端实例。
        :param lock_key: 锁的名称。
        :param lock_timeout: 锁的默认过期时间(秒)。
        """
        self.redis = redis_client
        self.lock_key = lock_key
        self.lock_timeout = lock_timeout
        # 生成唯一的锁 ID,标识当前持有者
        self.lock_id = uuid.uuid4().hex
        # 用于存储当前线程的重入计数 (本地缓存,减少Redis访问)
        self._local_count = 0

        # --- Lua 脚本定义 ---
        # 使用 Lua 脚本保证操作的原子性

        # 1. 获取锁的 Lua 脚本
        # 如果锁不存在,创建它 (count=1)
        # 如果锁存在且 ID 匹配,重入 (count+1)
        # 如果锁存在但 ID 不匹配,返回失败
        self._acquire_script = self.redis.register_script("""
            local lock_key = KEYS[1]
            local lock_id = ARGV[1]
            local lock_timeout = tonumber(ARGV[2])

            local current_value = redis.call('GET', lock_key)

            if current_value == false then
                -- 锁不存在,创建它
                local new_value = json.encode({lock_id = lock_id, count = 1})
                redis.call('SET', lock_key, new_value, 'EX', lock_timeout)
                return 1 -- 成功
            else
                -- 锁已存在,解析其值
                local data = json.decode(current_value)
                if data.lock_id == lock_id then
                    -- 锁 ID 匹配,重入
                    data.count = data.count + 1
                    redis.call('SET', lock_key, json.encode(data), 'EX', lock_timeout)
                    return 1 -- 成功
                else
                    -- 锁被其他客户端持有
                    return 0 -- 失败
                end
            end
        """)

        # 2. 释放锁的 Lua 脚本
        # 如果锁不存在,返回成功
        # 如果锁存在且 ID 不匹配,返回失败
        # 如果锁存在且 ID 匹配,count-1
        #   - 如果 count > 0,更新 count 和过期时间
        #   - 如果 count == 0,删除锁
        self._release_script = self.redis.register_script("""
            local lock_key = KEYS[1]
            local lock_id = ARGV[1]
            local lock_timeout = tonumber(ARGV[2])

            local current_value = redis.call('GET', lock_key)

            if current_value == false then
                -- 锁已不存在,视为成功释放
                return 1 -- 成功
            end

            local data = json.decode(current_value)
            if data.lock_id ~= lock_id then
                -- 尝试释放他人的锁,失败
                return 0 -- 失败
            end

            -- 锁 ID 匹配,减少重入计数
            data.count = data.count - 1
            if data.count > 0 then
                -- 还有重入,更新计数和过期时间
                redis.call('SET', lock_key, json.encode(data), 'EX', lock_timeout)
            else
                -- 重入计数为 0,彻底释放锁
                redis.call('DEL', lock_key)
            end
            return 1 -- 成功
        """)

    def acquire(self, blocking: bool = True, timeout: Optional[float] = None) -> bool:
        """
        获取锁。
        :param blocking: 是否阻塞等待。
        :param timeout: 最长等待时间(秒)。如果为 None 且 blocking=True,则无限期等待。
        :return: 是否成功获取锁。
        """
        start_time = time.time()

        while True:
            # 执行 Lua 脚本尝试获取锁
            # KEYS = [self.lock_key], ARGV = [self.lock_id, self.lock_timeout]
            result = self._acquire_script(keys=[self.lock_key], args=[self.lock_id, str(self.lock_timeout)])

            if result == 1:
                self._local_count += 1
                return True
            else:
                if not blocking:
                    return False
                
                # 检查是否已超过等待时间
                if timeout is not None and (time.time() - start_time) > timeout:
                    return False
                
                # 短暂休眠后重试,避免 CPU 空转
                time.sleep(0.1)

    def release(self) -> bool:
        """
        释放锁。
        :return: 是否成功释放。
        """
        if self._local_count == 0:
            # 本地计数为0,说明没有持有锁,直接返回失败
            return False

        # 执行 Lua 脚本尝试释放锁
        result = self._release_script(keys=[self.lock_key], args=[self.lock_id, str(self.lock_timeout)])
        
        if result == 1:
            self._local_count -= 1
            return True
        else:
            # 释放失败,可能是锁已过期或被他人持有,重置本地计数
            self._local_count = 0
            return False

    def __enter__(self):
        """支持 with 语句"""
        self.acquire()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """支持 with 语句"""
        self.release()

# --- 使用示例 ---
if __name__ == '__main__':
    # 1. 创建 Redis 连接
    r = redis.Redis(**REDIS_CONFIG)

    # 2. 定义一个需要加锁的共享资源操作
    def critical_section(lock_name):
        print(f"--- 进入临界区 ---")
        # 模拟一个耗时操作
        time.sleep(2)
        print(f"--- 离开临界区 ---")

    # 3. 测试可重入性
    print("--- 测试可重入性 ---")
    lock = ReentrantDistributedLock(r, 'reentrant_test_lock')
    
    print(f"第一次获取锁: {lock.acquire()}")  # True
    print(f"第二次获取锁 (重入): {lock.acquire()}") # True
    
    critical_section('reentrant_test_lock')
    
    print(f"第一次释放锁: {lock.release()}") # True (count变为1)
    print(f"第二次释放锁: {lock.release()}") # True (count变为0,锁被删除)
    print(f"第三次释放锁 (已无锁): {lock.release()}") # False

    print("\n--- 测试 with 语句 ---")
    with ReentrantDistributedLock(r, 'with_test_lock'):
        print("在 with 块内,锁已获取")
        critical_section('with_test_lock')
    print("退出 with 块,锁已自动释放")

    # 4. 测试安全性 (在另一个线程中尝试释放)
    import threading

    other_lock = ReentrantDistributedLock(r, 'safety_test_lock')
    release_success = [False]

    def try_release_from_another_thread():
        # 这个锁实例的 lock_id 与主线程的不同
        success = other_lock.release()
        release_success[0] = success

    print("\n--- 测试安全性 ---")
    with ReentrantDistributedLock(r, 'safety_test_lock'):
        print("主线程持有锁")
        t = threading.Thread(target=try_release_from_another_thread)
        t.start()
        t.join()
        print(f"其他线程尝试释放锁是否成功: {release_success[0]}") # False

    print("主线程释放锁后,程序结束")