V2EX 首页   注册   登录
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX  ›  程序员

redis 实现的一个锁有问题,求大神帮忙看看

  •  
  •   EchoUtopia · 125 天前 · 1953 次点击
    这是一个创建于 125 天前的主题,其中的信息可能已经有所发展或是发生改变。

    之前用 redis 实现了一个锁,但是发现这个锁并不能正常工作,经常两个进程同时获得锁,但是我实在看不出哪一步出现问题了,求大家帮忙看看,或者教教我怎么调试,谢谢了。 代码:

            def lock(self):
                _lock_key = self._key['_lock:_**']
                re = self._re
                while True:
                    get_stored = re.get(_lock_key)
                    if get_stored:
                        time.sleep(0.01)
                    else:
                        if re.setnx(_lock_key, 1):
                            re.expire(_lock_key, 5)
                            return True
            
        	def unlock(self):
                    _lock_key = self._key['_lock:_**']
                    pipeline = self._re.pipeline
                    with pipeline() as p:
                    try:
                        p.watch(_lock_key)
                        p.multi()
                        p.delete(_lock_key)
                        p.execute()
                    except:
                        sys.stderr.write("not deleted\n")
    

    测试方法:5个进程循环20次不断获取锁,sleep 0.01 秒,释放锁。

    def test_mutex(name, thread_num):
        for i in xrange(20):
            mutex = Mutex(name, timeout=5)
            mutex.lock()
            sys.stderr.write("locked\n")
            time.sleep(0.01)
            mutex.unlock()
            sys.stderr.write(thread_num + "---unlocked\n\n")
    

    之前 unlock 是简单的 delete 掉 key,然后怀疑delete时已经超时,就改成上面的实现方式,结果还是不行。能帮忙分析下哪步有问题吗,谢了。

    第 1 条附言  ·  125 天前
    ```
    while True:
    result = re.setnx(_lock_key, "locked")
    if not result:
    time.sleep(0.01)
    else:
    re.expire(_lock_key, self._timeout)
    return
    ```
    这个是我的实现方式,正文里是一个大神的实现方式,都有问题
    31 回复  |  直到 2017-07-18 15:31:14 +08:00
        1
    sampeng   125 天前
    多进程操作如果不能保证是原子的。。这种中心锁就没有意义。。。
        2
    tr0uble   125 天前
    每次 set 的时候设一个随机字符串进去,删的时候要这个字符串匹配才删

    另外:高版本的 set 可以通过加参数实现 nx 和 过期的功能,你可以看看你这个库支不支持

    可能并没有解决你的问题,2333
        3
    RubyJack   125 天前
    https://redis.io/topics/distlock redis 本身有方案的
        4
    luoqeng   125 天前
    调换一下顺序试试,有可能已经解锁,然后另一个进程显示 locked,而当前进程也还没来得及显示 unlocked。
    应该是多线程吧,看函数参数。多进程也不好观察调试。

    sys.stderr.write(thread_num + "---unlocked\n\n")
    mutex.unlock()
        5
    sampeng   125 天前   ♥ 1
        6
    awanabe   125 天前
    nx 就行了,加个 ttl
        7
    EchoUtopia   125 天前
    @sampeng 多线程也是一样,setnx 官方文档并没有说 setnx 是否是原子操作,但网上很多资料都把它当原子操作使用

    @tr0uble 这个我考虑过,是因为获得锁的实例超时后导致把别人的锁给删掉,我这个超时时间设的5秒,获得锁的时间为 0.01 秒,我打印时间也表明没有超时

    @RubyJack
    @sampeng 这个我还没有去看,我现在只是很难过,我不知道到底哪出问题了,并且我没有一点办法,因为太菜,连调试的思路都没有,我之前假装 strace 了以下,问题又不重现了,估计是竟态条件不满足了。


    @luoqeng 有可能是这个原因,但是线上时不时的出问题,应该是有问题的,线上的情景是:新创建用户我们给以下操作加锁:获取最后一个用户id,然后加一个随机数作为新用户id。然后并发的时候两个新用户获取到的 last_id 相同,并且随机数相同了,导致出问题。。
        8
    luoqeng   125 天前
    「例如某个客户端获得了一个锁,但它的处理时长超过了锁的有效时长,之后它删除了这个锁,而此时这个锁可能又被其他 d 客户端给获得了。仅仅做删除是不够安全的,很可能会把其他客户端的锁给删了。结合上面的代码,每个锁都有个唯一的随机值,因此仅当这个值依旧是客户端所设置的值时,才会去删除它。」 可能就是这个问题吧,引用上面回复的文章: http://zhangtielei.com/posts/blog-redlock-reasoning.html。
        9
    zts1993   125 天前   ♥ 1
    setnx 没有问题. 可以说是原子的. redis 不可能在处理中打断去处理其他命令,这点可以看 redis 源码.


    lock : re.setnx 返回值是什么,我不是太清楚,没有怎么使用 py client, 但是 lock 前几行代码是没有意义得, 你直接根据 setnx 返回值判断就好了, 可靠的. 还有一个问题, 可能需要加上超时时间(防止程序挂掉)
    因此应该使用 setnx + setex 也就是那个带有 4 个参数得 set 命令. 具体可以查 redis command


    unlock : 写的莫名其妙而且没有任何用处, transaction 使用也不对.


    关于锁的释放 : 如果你要保证 delete 时候一定是释放自己得,应该使用 lua 脚本去判断 value 然后 delete,同时创建得时候需要给 id.


    结论,不推荐 redis 在严谨的场景下做分布式锁, 即使是 redlock 都很有争议.
        11
    lolizeppelin   125 天前
    我代码都是基于协程的, 不折腾多线程
        12
    lolizeppelin   125 天前
    我的做法是 第一次 set 的时候只有一个很短的 ttl
    成功后在延长这个 key 的生存时间为需要锁定的时间
        13
    EchoUtopia   125 天前
    @luoqeng 之前我说了,我测验的时候发现并没有超时,并且我的实现里面有 watch key,如果已经超时,应该是不会去删除 key 的

    @zts1993 嗯,这个是别人的 lock,我的 lock 是直接去 setnx 的,都不行。超时时间是加了的,在 setnx 成功后,感觉这一步应该没问题,redis.py 没看到 set nx ex 一条命令的用法,要用 lua 脚本,我待会去试试。unlock 的 transaction 怎么用呢,这个是我为了超时加的,但是我的脚本里没有超时,这也是验证过的。


    @lolizeppelin 协程多进程下还是会有同样的问题吧,你这个 ttl 操作有啥特殊原因么
        14
    EchoUtopia   125 天前
    @zts1993 那个 unlock 按我的理解是,如果 key 被其他人删了,那么会触发它的 watch,然后就不删除key了
        15
    zts1993   125 天前
    @EchoUtopia 太复杂了。
        16
    EchoUtopia   125 天前
    @lolizeppelin 你这个异步代码写的好6啊、

    @zts1993 什么太复杂了
        17
    mansur   125 天前
    只是生成新用户 id 吗?用 mysql 的自增 id,生产了以后插入 redis 队列,取新 id 的时候直接从队列读不就行了。
        18
    lolizeppelin   125 天前
    1. setnx key 用很短的 ttl 比如 1.5s value 为相关的 id,
    用这个 ttl 是因为我的锁是有层级的,设置多个 key 中途会超时
    这特短时间的 ttl 能有效释放已经锁住的上层

    2. set 成功后,添加一个定时器,定时器触发时间是外部的锁定时间,到时触发删除 key 并通知超时
    3. 延长这个 key 的生存时间为 外部所用锁定时间

    锁删除之前,先校验 value
    这是我的锁的做法


    ---
    如果只要简单的原子锁,set 直接用
    set(key, value, px=int(timeout)+3, nx=True)
    来设置时间不就好了

    不要先 setnx 再 expire
        19
    zts1993   125 天前
    @EchoUtopia 因为你 watch 前,锁可能被人占了。所以这个 transaction 没有意义。
        20
    fds   125 天前
    首先你这个需求不用 redis 锁,直接在数据库准备个计数器,increase 一个字段,用返回值作为新 id 即可。

    如果要在 redis 里用锁,一般都要用 lua 脚本,比如下面这个是类似 setex_if_equal,传个锁的 key,过期时间,和随机生成个 UUID 传入即可
    ```
    local k = KEYS[1]
    local ex = ARGV[1]
    local eq = ARGV[2]
    local v = ARGV[3] or eq
    local c = redis.call("GET", k)
    if not c or c == eq then
    redis.call("SETEX", k, ex, v)
    return 1
    end
    return 0
    ```
    然后写个类似的删除脚本。
    脚本的运行过程中,redis 保证是原子的。你用 watch 什么的我怀疑效果。
        21
    EchoUtopia   125 天前
    @mansur 最后就是这样改的,但是这个问题还没解决

    @lolizeppelin 你这个是有用到生产环境么,另外你有测试多进程情况吗。那个 setnx 再 expire 应该没问题把,因为 setnx 是原子操作,同时只会有一个实例设置成功,成功后再expire应该也没啥影响吧,没使用一条命令是因为python的redis客户端不支持这样操作


    @zts1993 我的理解是如果锁已经被其他实例占用,那么这个 multi 的命令不会执行,不知道这样理解对不对
        22
    zts1993   125 天前
    @EchoUtopia 问题是开始 watch 得时候 已经改变了. watch 保护的是开始 watch 到你操作这个开始执行得这段时间. 这个时间很短得吧
        23
    EchoUtopia   125 天前
    @zts1993 哦,这个意思啊,懂了。不过现在遇到的这个问题应该不是打印的,我打印的 lock 到 unlock 的时间都没超过1秒
        24
    lolizeppelin   125 天前
    这个只要服务端支持就可以
    新版的 python-redis 支持
    旧版的 python 的 redis 客户端不支持可以自己封装
    python-redis 的源码很简单的,怎么封装自己过一便
    话说你们连 python-redis 的源码都没看过?

    能一次操作当然要一次做,你先 set 在 expire 分成了两次通信
    间隔较大的情况下你 expire 失败了回头删 key 搞不好就不是你设置的 key 了

    而且还影响性能
    本来你这个需求(用于约束用户 id )就会有不小的性能问题,还分两次问题更加多

    顺便,楼上也有人提到了,约束用户 id 不应该用锁来实现
    如果只是想唯一 key 的话,比较好的做法是程序那边实现一个类似 Snowflake 的唯一主键生成即可
    比用 redis 队列 mysql 字段来弄这性能好多了

    我那玩意是写给我的运维管理工具用的,算是写着玩的,不要拿去直接用,有问题不负责 233
        25
    sagaxu   125 天前 via Android
    @EchoUtopia 假设 A 获得的 last_id 是 100,B 获得的 last_id 是 200,A 的随机数是 300,B 的随机数是 200,你就有两个 400 了
        26
    lcqtdwj   125 天前
    @zts1993 有什么比较严谨的分布式锁可以用吗? zookeeper?
        27
    EchoUtopia   125 天前
    @sagaxu 我表述有误,不是随机数,是 random.choice(一个已定义的列表)

    @lolizeppelin redis 在本地,没考虑过这个问题。后面实现改成把 last_user_id 放 redis 了。更改去看源码的时候,突然发现 python redis 自己就实现了一个锁,233
        28
    EchoUtopia   125 天前
    @lolizeppelin 我使用了新版的 redis 模块:re.set(_lock_key, "locked", nx=True, ex=self._timeout),结果还是一样的,回头再试试这个模块自带的锁
        29
    stone1342006   125 天前
    先 get 在 setnx 这个没法保证原子性啊
        30
    lolizeppelin   125 天前 via Android
    有问题肯定是你释放有问题捏
        31
    EchoUtopia   124 天前
    @stone1342006
    那个应该没影响,我改成 re.set(_lock_key, "locked", nx=True, ex=self._timeout)是一样的

    @lolizeppelin
    ```
    def unlock(self):
    _lock_key = self._key['_lock:_HolytreeTech']
    pipeline = self._re.pipeline
    with pipeline() as p:
    try:
    p.watch(_lock_key)
    lock_ident = p.get(_lock_key)
    p.multi()
    if lock_ident != self._ident:
    return
    p.delete(_lock_key)
    p.execute()
    except:
    sys.stderr.write("not deleted\n")
    ```
    我 unlock 的时候判断了下是不是自己的锁,结果还是一样
    DigitalOcean
    关于   ·   FAQ   ·   API   ·   我们的愿景   ·   广告投放   ·   鸣谢   ·   1789 人在线   最高记录 3541   ·  
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.0 · 50ms · UTC 12:16 · PVG 20:16 · LAX 04:16 · JFK 07:16
    ♥ Do have faith in what you're doing.
    沪ICP备16043287号-1