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

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

  •  
  •   EchoUtopia · 2017-07-17 13:18:38 +08:00 · 5492 次点击
    这是一个创建于 2684 天前的主题,其中的信息可能已经有所发展或是发生改变。

    之前用 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 条附言  ·  2017-07-17 16:42:14 +08:00
    ```
    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
    sampeng
        1
    sampeng  
       2017-07-17 15:41:44 +08:00
    多进程操作如果不能保证是原子的。。这种中心锁就没有意义。。。
    tr0uble
        2
    tr0uble  
       2017-07-17 15:46:59 +08:00
    每次 set 的时候设一个随机字符串进去,删的时候要这个字符串匹配才删

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

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

    sys.stderr.write(thread_num + "---unlocked\n\n")
    mutex.unlock()
    sampeng
        5
    sampeng  
       2017-07-17 15:49:38 +08:00   ❤️ 1
    awanabe
        6
    awanabe  
       2017-07-17 15:57:49 +08:00
    nx 就行了,加个 ttl
    EchoUtopia
        7
    EchoUtopia  
    OP
       2017-07-17 16:00:30 +08:00
    @sampeng 多线程也是一样,setnx 官方文档并没有说 setnx 是否是原子操作,但网上很多资料都把它当原子操作使用

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

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


    @luoqeng 有可能是这个原因,但是线上时不时的出问题,应该是有问题的,线上的情景是:新创建用户我们给以下操作加锁:获取最后一个用户id,然后加一个随机数作为新用户id。然后并发的时候两个新用户获取到的 last_id 相同,并且随机数相同了,导致出问题。。
    luoqeng
        8
    luoqeng  
       2017-07-17 16:17:52 +08:00
    「例如某个客户端获得了一个锁,但它的处理时长超过了锁的有效时长,之后它删除了这个锁,而此时这个锁可能又被其他 d 客户端给获得了。仅仅做删除是不够安全的,很可能会把其他客户端的锁给删了。结合上面的代码,每个锁都有个唯一的随机值,因此仅当这个值依旧是客户端所设置的值时,才会去删除它。」 可能就是这个问题吧,引用上面回复的文章: http://zhangtielei.com/posts/blog-redlock-reasoning.html。
    zts1993
        9
    zts1993  
       2017-07-17 16:33:34 +08:00   ❤️ 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 都很有争议.
    lolizeppelin
        10
    lolizeppelin  
       2017-07-17 16:36:14 +08:00
    lolizeppelin
        11
    lolizeppelin  
       2017-07-17 16:36:52 +08:00
    我代码都是基于协程的, 不折腾多线程
    lolizeppelin
        12
    lolizeppelin  
       2017-07-17 16:39:25 +08:00
    我的做法是 第一次 set 的时候只有一个很短的 ttl
    成功后在延长这个 key 的生存时间为需要锁定的时间
    EchoUtopia
        13
    EchoUtopia  
    OP
       2017-07-17 16:53:03 +08:00
    @luoqeng 之前我说了,我测验的时候发现并没有超时,并且我的实现里面有 watch key,如果已经超时,应该是不会去删除 key 的

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


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

    @zts1993 什么太复杂了
    mansur
        17
    mansur  
       2017-07-17 17:06:25 +08:00
    只是生成新用户 id 吗?用 mysql 的自增 id,生产了以后插入 redis 队列,取新 id 的时候直接从队列读不就行了。
    lolizeppelin
        18
    lolizeppelin  
       2017-07-17 17:08:47 +08:00
    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
    zts1993
        19
    zts1993  
       2017-07-17 17:10:27 +08:00
    @EchoUtopia 因为你 watch 前,锁可能被人占了。所以这个 transaction 没有意义。
    fds
        20
    fds  
       2017-07-17 17:29:37 +08:00
    首先你这个需求不用 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 什么的我怀疑效果。
    EchoUtopia
        21
    EchoUtopia  
    OP
       2017-07-17 17:31:58 +08:00
    @mansur 最后就是这样改的,但是这个问题还没解决

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


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

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

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

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

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

    @lolizeppelin redis 在本地,没考虑过这个问题。后面实现改成把 last_user_id 放 redis 了。更改去看源码的时候,突然发现 python redis 自己就实现了一个锁,233
    EchoUtopia
        28
    EchoUtopia  
    OP
       2017-07-17 19:02:02 +08:00
    @lolizeppelin 我使用了新版的 redis 模块:re.set(_lock_key, "locked", nx=True, ex=self._timeout),结果还是一样的,回头再试试这个模块自带的锁
    stone1342006
        29
    stone1342006  
       2017-07-17 19:25:03 +08:00
    先 get 在 setnx 这个没法保证原子性啊
    lolizeppelin
        30
    lolizeppelin  
       2017-07-17 19:45:10 +08:00 via Android
    有问题肯定是你释放有问题捏
    EchoUtopia
        31
    EchoUtopia  
    OP
       2017-07-18 15:31:14 +08:00
    @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 的时候判断了下是不是自己的锁,结果还是一样
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3182 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 12:20 · PVG 20:20 · LAX 04:20 · JFK 07:20
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.