Redis实战——使用Redis构建应用程序组件(上)构建自己的锁和信号量

简单锁#

使用multiexecwatch的组合在高负载情况下经常会陷入重试之中,这限制了这种锁的可扩展性。下图给出了在不同负载情况下锁的平均等待时间,因为在高负载的情况下系统经常陷入到重试中,所以平均等待时间会变得非常大。

下面我们实现一种简单的互斥锁来降低系统中的重试次数,当获取锁失败时,操作静默失败。setnx命令只有在指定的键不存在时才会设置,所以它很适合用来实现互斥锁:

"""
尝试获取锁,该方法会阻塞,直到获取到锁或者获取锁超时
lockname是你要获取的锁的名字,比如可能应用中约定对商品库加锁使用名字`inventory`
返回False代表锁请求失败,否则,返回值就是你获得的锁标识,稍后你需要使用该锁标识来解锁
"""
def aquire_lock(conn: redis.StrictRedis,lockname, acquire_timeout=10):

    end = time.time() + acquire_timeout
    identifier = str(uuid.uuid4())

    while time.time() < end:
        if conn.setnx('lock:' + lockname, identifier):
            return identifier
        time.sleep(0.001)

    return False

解锁操作很简单,需要判断当前用户是否是加锁用户,即判断identifier是否是当前保存的identifier

"""
释放锁
False 释放失败,在这种情况下,也许客户端根本就没有锁
True  释放成功
"""
def release_lock(conn: redis.StrictRedis, lockname, identifier):
    lockname = 'lock:' + lockname
    if conn.get(lockname) != identifier:
        return False
    else:
        conn.delete(lockname)
        return True

如果所有客户端都遵循该加锁协议,并且除了这些客户端,并没有其它人会更改这些锁数据,那么上面的代码应该是安全的,虽然这里有一个先检查后执行操作,但问题是不会有人在这段检查执行操作之间被允许修改lock的值,反正我看不出什么安全问题,但书上没这么写,书上使用了watch来确保不会有其它客户端修改锁:


"""
释放锁
False 释放失败,在这种情况下,也许客户端根本就没有锁
True  释放成功
"""
def release_lock(conn: redis.StrictRedis, lockname, identifier):
    lockname = 'lock:' + lockname
    pipe = conn.pipeline()

    while True:
        pipe.watch(lockname)
        if pipe.get(lockname) != identifier:
            pipe.unwatch()
            return False

        try:
            pipe.multi()
            pipe.delete(lockname)
            pipe.execute()
            return True
        except redis.exceptions.WatchError:
            pass

        return False

下面是使用这个简单互斥锁进行游戏市场交易的用例:

def list_item(conn, itemid, sellerid, price):
    invetory_name = "inventory:%s" % sellerid
    market_item_name = "%s.%s" % (itemid, sellerid)
    end = time.time() + 5

    pipe = conn.pipeline()

    while time.time() < end:
        try:
            pipe.watch(invetory_name)
            if not pipe.sismember(invetory_name, itemid):
                pipe.unwatch()
                return None

            pipe.multi()
            pipe.zadd("market:", {market_item_name: price})
            pipe.srem(invetory_name, itemid)
            pipe.execute()
            return True
        except redis.exceptions.WatchError:
            pass

        return False


def purchase_item(conn, buyerid, itemid, sellerid, lprice):
    buyer = "users:%s" % buyerid
    seller = "users:%s" % sellerid
    market_item_name = "%s.%s" % (itemid, sellerid)
    buyer_inventory_name = "inventory:%s" % buyerid

    end = time.time() + 10

    while time.time() < end:
        try:
            pipe = conn.pipeline()
            pipe.watch(buyer, "market:") # 如果购买者信息发生变化(资产发生变化)或者市场发生变化(要买的商品已经被买走)
            buyer_funds = int(pipe.hget(buyer, "funds"))
            price = pipe.zscore("market:", market_item_name)

            if buyer_funds < price:
                pipe.unwatch()
                return None

            pipe.multi()
            pipe.hincrby(buyer, "funds", -int(price))
            pipe.hincrby(seller, "funds", int(price))
            pipe.sadd(buyer_inventory_name, itemid)
            pipe.zrem("market:", market_item_name)
            pipe.execute()
            return True
        except:
            pass

    return False

下图展示了使用锁带来的性能提升,书上给的

可以看出,上架商品数量变少了,但是重试次数变少了,并且买入商品数量和上架商品数量比率变得正常了。

下面是我进行测试的结果,我是6.0.6版本,这里我只测试了十秒钟,时间的单位是毫秒。请注意,我的测试结果中并不能体现出WATCH的重试次数。

使用锁
上架失败次数: 0
非锁原因上架失败次数: 0
上架成功次数: 1982
上架商品数量: 1982
总共消耗时间: 48818.967600 ms
每次消耗时间: 24.631164 ms
购买失败次数: 0
非锁原因购买失败次数: 0
购买成功次数: 1484
购买数量: 1484
购买消耗总时间(这里是纳秒): 38329913000.000000 ns
每次购买消耗时间: 25.828782 ms


使用WATCH
上架失败次数: 0
非锁原因上架失败次数: 0
上架成功次数: 9287
上架商品数量: 9287
总共消耗时间: 33824.781600 ms
每次消耗时间: 3.642164 ms
购买失败次数: 4
非锁原因购买失败次数: 0
购买成功次数: 173
购买数量: 177
购买消耗总时间(这里是纳秒): 51006110600.000000 ns
每次购买消耗时间: 288.170116 ms

同时,自己构建的锁允许我们把锁粒度限定在某一个商品而非整个市场上,但WATCH不行,如果你使用更细粒度的锁,那么竞争将进一步被减少。

带有超时限制特性的锁#

在实现上面的锁时,最开始难免有BUG导致程序崩溃,然后我便发现,当一个客户端持有了锁之后(在没释放锁之前)崩溃,那么这个锁将一直保存在Redis中,没有任何一个人能删除掉它,所以,最开始我的锁实现导致10秒钟内才上架了一个商品,销售了0个商品,我当时还感到纳闷。

现实开发中难免出现这种问题,就算你再小心,你的锁也可能没有被安全的释放(比如客户端宕机)导致系统直接变成不可用的,所以,一个具有超时特性的锁是必须的,如果固定的超时时间之后锁还没被释放,那么就自动释放该锁。

def aquire_lock(conn: redis.StrictRedis,lockname, acquire_timeout=10, lock_timeout=10):

    end = time.time() + acquire_timeout
    identifier = str(uuid.uuid4())
    lockname = 'lock:' + lockname

    while time.time() < end:
        if conn.setnx(lockname, identifier):
            conn.expire(lockname, lock_timeout)
            return identifier
        elif conn.ttl(lockname) == -1: # key存在但没有设置超时时间
            conn.expire(lockname, lock_timeout)

        time.sleep(0.001)

    return False

这个锁可以很方便的和之前的无超时时间的锁一起工作,并且它还会为无超时时间的锁设置超时时间,它也可以直接和之前的释放锁方法协同工作。

计数信号量#

信号量也可以被看作是一种锁,一个值为N的信号量,被它锁住的资源允许在同一时间内被N个客户端访问,当你获取它,N-1,当你释放它N+1,当N为0,获取失败。

"""
申请一个计数信号量
semname为信号量的名称
limit为信号量的最大值
timeout为信号量超时时间

返回None获取失败,否则返回值就是客户端获取到的信号量的唯一标识,稍后释放会用到

信号量被存在一个zset中,它的键是信号量的唯一标识,它的值是创建信号量的时间
"""
def acquire_semaphore(conn: redis.StrictRedis, semname, limit, timeout=10):
    identifier = str(uuid.uuid4())
    now = time.time()

    pipe = conn.pipeline(transaction=True)
    # 移除所有超时信号量
    pipe.zremrangebyscore(semname, '-inf', now - timeout)
    # 尝试添加信号量
    pipe.zadd(semname, {identifier: now})
    # 获取信号量排名
    pipe.zrank(semname, identifier)
    # 如果没有超过限制
    if pipe.execute()[-1] < limit:
        return identifier

    # 移除之前添加的信号量
    conn.zrem(semname, identifier)
    return None

计数信号量的释放很简单

def release_semaphore(conn, semname, identifier):
    return conn.zrem(semname, identifier)

如果依赖客户端的时间,那么很容易出现问题,假设limit是5,获取第六个信号量的客户端系统时间比第五个慢1秒,那么很有可能在acquire_semaphorepipe.zrank中返回的不是5而是4,这样,系统中有了6个客户端获得了信号量。而且在pipe.zremrangebyscore中,系统时间比较慢的系统可能会删掉多余的信号量,即使在获得它们的客户端看来它们并没有过期。

公平信号量#

其实我们只需要调节各个客户端的系统时间,让它们之间的差值不超过1秒就不会出现太大问题。但是我们还是尝试通过一些更加公平的办法减少系统时间差异带来的影响。

这次,我们使用一个不断自增的计数器来记录信号量的分值,这样,系统时间就带不来影响了。不过为了实现超时自动销毁特性,原有的zset也被保留了。

"""
${semname}:counter   字符串   维护该信号量的计数器
${semname}:owner     zset    维护当前所有信号量的表  identifier -> 创建时的counter
${semname}           zset    维护当前所有信号量的创建时间 用于实现超时 identifier -> 创建时的客户端系统时间


"""
def fair_acquire_semaphore(conn: redis.StrictRedis, semname, limit, timeout=10):
    now = time.time()
    counter = "%s:counter" % semname
    owner = "%s:owner" % semname
    identifier = str(uuid.uuid4())

    pipe = conn.pipeline(transaction=True)

    # 删除超时的信号量
    pipe.zremrangebyscore(semname, '-inf', now - timeout)
    pipe.zinterstore(owner, [owner, semname])

    num = pipe.incr(counter).execute()[-1]
    pipe.zadd(owner, {identifier: num})
    conn.zadd(semname, {identifier: now})
    pipe.zrank(owner, identifier)
    if pipe.execute()[-1] < limit:
        return identifier
    pipe.zrem(semname, identifier)
    pipe.zrem(owner, identifier)
    pipe.execute()
    return None
def fair_release_semaphore(conn: redis.StrictRedis, semname, identifier):
    pipe = conn.pipeline()
    pipe.zrem(semname, identifier)
    pipe.zrem("%s:owner"%semname, identifier)
    return pipe.execute()[0]

消除信号量中的竞态条件#

公平的信号量会产生竞态条件,因为它的操作并不是一个原子操作,它被分割成了两个原子操作(实际是三个,但最后一个不影响什么)。消除这种竞态条件听起来好像很难,Redis中并没有现成的解决办法,但是我们只需要把刚刚实现的锁拿来保护信号量即可消除这种竞态条件。因为锁能保证一段Redis命令的互斥访问。

def fair_acquire_semaphore_safely(conn, semname, limit, time_out=10):
    lock_id = simple_lock.aquire_lock(conn, semname, 0.01)
    if lock_id:
        try:
            return fair_acquire_semaphore_safely(conn, semname, limit, time_out)
        finally:
            simple_lock.release_lock(conn, semname, lock_id)
posted @   yudoge  阅读(85)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· winform 绘制太阳,地球,月球 运作规律
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示
主题色彩