Bootstrap

无惧高并发!Python+Redis分布式锁,让你的业务高效稳定!

锁是什么?

编程中的锁是控制不同线程之间访问共享资源的一种实现,需要实现互斥,来防止彼此干扰,来保证数据一致性。

门 和 锁的比喻

人是不同的线程,卫生间是共享资源
你在上洗手间的时候肯定要把门锁上吧,这就是加锁,只要你在里面,这个卫生间就被锁了,只有你出来之后别人才能用。想象一下如果卫生间的门没有锁会是什么样?

应用场景

使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作。

根据锁的用途还可以细分为以下两类

  1. 允许多个客户端操作共享资源

    这种情况下,对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。在这里使用锁,无外乎就是为了避免重复操作共享资源从而提高效率。

  2. 只允许一个客户端操作共享资源

    这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。

怎么实现?

  1. 加锁
  2. 释放锁

不要着急,下面我们开始的实现它,以下我们会使用redis实现分布式锁。
继续看下去吧~

加锁操作

redis中执行命令setnx key val

setnx 意思是 SET if Not eXists
当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0

那么意味者,系统认定成功更改值的线程持有该锁,有没问题(没有问题)?

释放锁:

redis中执行命令 del key

删除redis中这个值,意味着释放了这把锁,线程就可以重新争抢这把锁

现在就已经实现里加锁和释放锁,是不是已经完成了?

问题

线程获得锁后程序出错/系统宕机还没来得及释放造成死锁?

  1. 使用try…finally 确保获取锁结束后必然会释放锁
  2. 为每个锁添加一个过期时间

如何设置超时时间?

redis中执行命令 expire key timeout
为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁

由于setnx和expire是两部操作,如果在setnx时还未进行expire程序宕机也会出现死锁问题?

解决方案:set key val NX EX 10 原子性的操作同时给key设置值和加过期时间

释放锁的设计

当线程a准备释放锁,此时线程a的锁已经自然过期,线程b重新占用的了锁,那么线程a准备释放的锁就是线程刚刚获得锁。

只允许释放自己上加的锁

解决方案: 加锁设置的值不再是1 ,换成一个线程的唯一标识。
set lock uuid NX EX 10 uuid是一个唯一标识

解决这个问题同时引入一个新的问题?

释放锁之前,要先查询下锁的这个值是不是自己加上的,然后执行del操作,由于查询和删除不是原子操作,(例如:查询时发现是自己上的锁要准备释放锁,这是锁已经自然过期,在极短时间内另一个线程获取了这个锁,这时候准备释放锁的操作会错误的释放掉这个刚刚获得的锁)

解决方案:那就需要保证查询和删除是原子性。
由于redis的命令不支持 查询和删除是原子操作
那么我们通过发送lua脚本给redis执行保证原子性

在这里插入图片描述
书接上文:
如何设置合理的超时时间?

设置一个较短的过期时间(10s,20s

可能遇到网络问题,慢查询超过了锁过期时间,业务逻辑还未执行完成,锁自然释放了,这显然不是我们想看到的。

那就设置一个较长的过期时间(1h,2h

设置时间过长,一旦发生宕机重启,就意味着 1 小时内,分布式锁的服务全部节点不可用,让运维手动删除这个锁么?这也增加了运维成本,也不是我们想要的。

动态的过期时间

有一个动态的过期时间,能在我程序未执行完成,不断给我增加时间,这是不是能解决我们的问题呢?

这里引入一个续锁的机制

让获得锁的线程开启一个守护线程,用来给快要过期的锁「续航」

  1. 如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。

这个守护线程我们一般也把它叫做「看门狗」线程
后面我们用python语言实现这样一个看门狗线程。
所以这就完成所有redis锁的设计了吗?
我们还可以优化锁的实现

可重入锁

可重入锁指的是在一个线程中可以多次获取同一把锁
a,b方法使用同一个锁的情况:
假设 X 线程在 a 方法获取锁之后,a方法调用 b 方法,如果此时不可重入,线程就必须等待锁释放,自己等待自己释放显然是不会有结果的。
实现起来也很简单,锁的value值改为hash类型对应uid的值是数值,重入一次增加1,释放则减少1.

下面我们用python代码来实现。

python实现

# -*- coding: utf-8 -*-
# @DateTime : 2022/8/25 17:38
# @Author   : charlesxie
import threading
import uuid
import weakref

import redis
import time

LOCK_SCRIPT = b"""
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('expire', KEYS[1], ARGV[1]);
    return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('expire', KEYS[1], ARGV[1]);
    return 1;
end ;
return 0;
"""
UNLOCK_SCRIPT = b"""
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end ;
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end ;
return nil;
"""
RENEW_SCRIPT = b"""
if redis.call("exists", KEYS[1]) == 0 then
    return 1
elseif redis.call("ttl", KEYS[1]) < 0 then
    return 2
else
    redis.call("expire", KEYS[1], ARGV[1])
    return 0
end
"""


class RedisLock:
    """
    redis实现互斥锁,支持重入和续锁
    """

    def __init__(self, conn, lock_name, expire=30, uid=None, is_renew=True):
        self.conn = conn
        self.lock_script = None
        self.unlock_script = None
        self.renew_script = None
        self.register_script()

        self._name = f"lock:{lock_name}"
        self._expire = int(expire)
        self._uid = uid or str(uuid.uuid4())

        self._lock_renew_interval = self._expire * 2 / 3
        self._lock_renew_threading = None

        self.is_renew = is_renew
        self.is_acquired = None
        self.is_released = None

    @property
    def id(self):
        return self._uid

    @property
    def expire(self):
        return self._expire

    def acquire(self):
        result = self.lock_script(keys=(self._name,), args=(self._expire, self._uid))
        if self.is_renew:
            self._start_renew_threading()
        self.is_acquired = True if result else False
        print(f"争抢锁:{self._uid}-{self.is_acquired}\n")
        return self.is_acquired

    def release(self):
        if self.is_renew:
            self._stop_renew_threading()

        result = self.unlock_script(keys=(self._name,), args=(self._uid,))
        self.is_released = True if result else False
        print(f"释放锁{self.is_released}")
        return self.is_released

    def register_script(self):
        self.lock_script = self.conn.register_script(LOCK_SCRIPT)
        self.unlock_script = self.conn.register_script(UNLOCK_SCRIPT)
        self.renew_script = self.conn.register_script(RENEW_SCRIPT)

    def renew(self, renew_expire=30):
        result = self.renew_script(keys=(self._name,), args=(renew_expire,))
        if result == 1:
            raise Exception(f"{self._name} 没有获得锁或锁过期!")
        elif result == 2:
            raise Exception(f"{self._name} 未设置过期时间")
        elif result:
            raise Exception(f"未知错误码: {result}")
        print("续命一波", result)

    @staticmethod
    def _renew_scheduler(weak_self, interval, lock_event):
        while not lock_event.wait(timeout=interval):
            lock = weak_self()
            if lock is None:
                break
            lock.renew(renew_expire=lock.expire)
            del lock

    def _start_renew_threading(self):
        self.lock_event = threading.Event()
        self._lock_renew_threading = threading.Thread(target=self._renew_scheduler,
                                                      kwargs={
                                                          "weak_self": weakref.ref(self),
                                                          "interval": self._lock_renew_interval,
                                                          "lock_event": self.lock_event
                                                      })

        self._lock_renew_threading.demon = True
        self._lock_renew_threading.start()

    def _stop_renew_threading(self):
        if self._lock_renew_threading is None or not self._lock_renew_threading.is_alive():
            return
        self.lock_event.set()
        # join 作用是确保thread子线程执行完毕后才能执行下一个线程
        self._lock_renew_threading.join()
        self._lock_renew_threading = None

    def __enter__(self):
        self.acquire()
        return self

    def __exit__(self, exc_type=None, exc_val=None, exc_tb=None):
        self.release()


def run_work(my_user_id):
    with RedisLock(redis_client, "test", uid=my_user_id, expire=5) as r:
        if r.is_acquired:
            print(f"just do it,{my_user_id}")
            time.sleep(20)
        else:
            print(f"quit, {my_user_id}")


if __name__ == '__main__':
    redis_client = redis.Redis(host="localhost", port=6379, db=2)
    a1 = threading.Thread(target=run_work, args=("charles",))
    a2 = threading.Thread(target=run_work, args=("xie",))

    a1.start()
    a2.start()

争抢锁:charles-True
争抢锁:xie-False

quit, xie

just do it,charles
释放锁False
续命一波 0
续命一波 0
续命一波 0
续命一波 0
续命一波 0
释放锁True

进程已结束,退出代码0

以上实现的方式都没提到分布式情况?

讲的是 redis通过主从模式哨兵模式集群模式部署成为一个redis集群,而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

同样分布式也会引入一些问题,

  • 如果你对某个redis master实例,写入锁数据,此时会异步复制给对应的master slave实例。

  • 但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

  • 接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁

  • 此时就会存在2个客户端同时拥有这把锁。

  • 这样的业务系统一定会出问题,导致各种脏数据的产生和数据不一致。

为了应对这个情形, redis的作者antirez提出了RedLock算法,
RedLock算法思想:

  1. 不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁
  2. 必须在大多数(n/2+1)redis节点上都成功创建锁,才能算这个整体的RedLock加锁成功

redlock原理

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例
    整体的流程是这样的,一共分为 5 步:
  3. 客户端先获取「当前时间戳T1」
  4. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  5. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  6. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  7. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

总结

你可看作是把5把小锁合并成一把大锁使用
那么开始我们的实现过程:

class RedLock:
    def __init__(self, masters, lock_name, expire=30, uid=None, is_renew=True):
        self.masters = masters
        self.lock_script = None
        self.unlock_script = None
        self.renew_script = None
        self.register_script()
        self._name = f"lock:{lock_name}"
        self._expire = int(expire)
        self._uid = uid or str(uuid.uuid4())

        self._lock_renew_interval = self._expire * 2 / 3
        self._lock_renew_threading = None
        # 是否开启续锁
        self.is_renew = is_renew
        self.is_acquired = None
        self.is_released = None

    @property
    def id(self):
        return self._uid

    @property
    def expire(self):
        return self._expire

    def __drift(self) -> float:
        return self._expire * 0.01 + .002

    def register_script(self):
        master = next(iter(self.masters))  # type: ignore

        self.lock_script = master.register_script(LOCK_SCRIPT)
        self.unlock_script = master.register_script(UNLOCK_SCRIPT)
        self.renew_script = master.register_script(RENEW_SCRIPT)

    def __acquire(self, master):
        result = self.lock_script(keys=(self._name,), args=(self._expire, self._uid), client=master)
        return result

    def acquire(self):
        futures = []
        success_acquired_num = 0
        start_time = timeit.default_timer()
        executor = ThreadPoolExecutor()
        for master in self.masters:
            futures.append(executor.submit(self.__acquire, master))
        executor.shutdown(True)
        for future in as_completed(futures):
            try:
                success_acquired_num += future.result()
            except Exception as e:
                print(f"{e}")
            else:
                if success_acquired_num > len(self.masters) // 2:
                    end_time = timeit.default_timer()
                    if end_time - start_time - self.__drift() < self._expire:
                        self.is_acquired = True

        if self.is_renew and self.is_acquired:
            self._start_renew_threading()
        print(f"{self._uid}-争抢锁:{success_acquired_num}-{self.is_acquired}\n")
        return self.is_acquired

    def __release(self, master):
        result = self.unlock_script(keys=(self._name,), args=(self._uid,), client=master)
        return result

    def release(self):
        if self.is_renew and self.is_acquired:
            self._stop_renew_threading()
        futures = []
        success = []
        executor = ThreadPoolExecutor()
        for master in self.masters:
            futures.append(executor.submit(self.__release, master))
        executor.shutdown(True)
        for future in as_completed(futures):
            result = future.result()
            if result == 1:
                success.append(result)
        self.is_released = True if len(success) > len(self.masters) // 2 else False
        print(f"释放锁{success}-{self.is_released}")
        return self.is_released

    def __renew(self, master, renew_expire):
        result = self.renew_script(keys=(self._name,), args=(renew_expire,), client=master)
        return result

    def renew(self, renew_expire=30):
        futures = []
        success = []
        executor = ThreadPoolExecutor()
        for master in self.masters:
            futures.append(executor.submit(self.__renew, master, renew_expire))
        executor.shutdown(True)
        for future in as_completed(futures):
            result = future.result()
            if result == 0:
                success.append(result)
        print("续命一波", success)

    def __enter__(self):
        self.acquire()
        return self

    def __exit__(self, exc_type=None, exc_val=None, exc_tb=None):
        self.release()

    def _stop_renew_threading(self):
        if self._lock_renew_threading is None or not self._lock_renew_threading.is_alive():
            return
        self.lock_event.set()
        # join 作用是确保thread子线程执行完毕后才能执行下一个线程
        self._lock_renew_threading.join()
        self._lock_renew_threading = None

    def _start_renew_threading(self):
        self.lock_event = threading.Event()
        self._lock_renew_threading = threading.Thread(target=self._renew_scheduler,
                                                      kwargs={
                                                          "weak_self": weakref.ref(self),
                                                          "interval": self._lock_renew_interval,
                                                          "lock_event": self.lock_event
                                                      })

        self._lock_renew_threading.demon = True
        self._lock_renew_threading.start()

    @staticmethod
    def _renew_scheduler(weak_self, interval, lock_event):
        while not lock_event.wait(timeout=interval):
            lock = weak_self()
            if lock is None:
                break
            lock.renew(renew_expire=lock.expire)
            del lock

if __name__ == '__main__':
    redis_client = redis.Redis(host="localhost", port=6379, db=2)
    redis_client2 = redis.Redis(host="localhost", port=6379, db=1)
    redis_client3 = redis.Redis(host="localhost", port=6379, db=3)
    redis_client4 = redis.Redis(host="localhost", port=6379, db=4)
    # 单机锁
    # a1 = threading.Thread(target=run_work, args=("charles",))
    # a2 = threading.Thread(target=run_work, args=("xie",))
    # 
    # a1.start()
    # a2.start()

    # 分布式锁
    masters = [redis_client, redis_client2, redis_client3, redis_client4]

    pool = ThreadPool(5)
    pool.starmap(run_work_distributed, [("u1",), ("u2",), ("u3",), ("u4",), ("u5",)])
    pool.close()
    pool.join()

官方推荐的轮子:
包含不同语言的不同设计,能满足大部分生产上的需求。
开箱即用,效果杠杠的。
在这里插入图片描述

;