前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >厉害了,原来分布式锁有这么多坑

厉害了,原来分布式锁有这么多坑

作者头像
用户3147702
发布2022-06-27 13:33:33
6380
发布2022-06-27 13:33:33
举报
文章被收录于专栏:小脑斧科技博客

1. 引言

并发环境下,多个系统相互协作,不可避免的,总是会有很多工作需要协调进行,此时就必须要引入分布式事务来进行整个任务的协调统筹,关于分布式事务的解决方案,我们已经进行过详细介绍。 分布式事务通用解决方案

但是,无论是哪种分布式事务解决方案,都不可缺少的需要分布式事务锁在关键节点进行锁定来保证对竞争条件访问的一致性。 目前最为常用的分布式事务锁解决方案有两种:通过 Redis 或 zookeeper 来实现,本文我们就来详细探讨一下通过 Redis 实现分布式事务锁的常见方案,以及每个方案所隐藏的坑,最终实现一个最为可靠与实用的分布式锁方案。

2. Redis 实现分布式锁的基本思想

Redis 能够用来实现分布式锁一来是由于 Redis 本身的分布式缓存的特性,同时,Redis 作为一个单线程存储服务,其大量的命令在执行中都是单线程独占的,从而实现了命令的原子性,这正是锁最重要的特性和基础。 只要多个分布式进程同时设置锁状态,最终只有一个进程能够获取到加锁成功的状态,这就是一个初步可用的分布式锁。 那么,如何来实现呢?下面我们就来慢慢道来。

3. 方案1 — set

SET key value

Redis 的 set 命令在保证原子性的同时,返回变更的条数。 依赖这个特性,我们就可以实现一个分布式锁:当多个分布式进程同时对一个 key 设置相同的值,由于 set 的原子性,只有首个 set 的进程可以获取到返回值 1,其他进程均会获取到返回值 0,从而实现锁状态的返回。

4. 方案2 — incrby

INCRBY key increment

上述的方案1实现了加锁与解锁,但与 set 方法相比,incrby 则可以实现的更为强大的锁机制 — 信号量。 我们可以预设一个值,并通过 incrby 传入 -1 来实现加锁操作,返回大于等于 0 则表示加锁成功,否则表示失败,失败后需再次调用 incrby 传入 1 来恢复此次加锁失败造成的影响。

5. 方案1改进 — setex

上面方案1和方案2乍看起来完全可以实现一个分布式事务锁,但他存在一个问题 — 一旦本应获取到锁的进程调用超时,他无法获取到加锁状态,安全起见,他不能假设自己已经获取到锁,因此重新获取锁,但此时锁已是加锁状态,由于没有任何进程实际持有锁,所有进程均无法再获取到锁,全部处于等待状态,从而陷入了死锁。 即便上述情况没有发生,获取到锁之后的进程仍然是有可能在解锁前崩溃的,这就造成了死锁。 如何解决上述两种场景下的死锁呢?最简单的方法就是在锁上加一个超时,一旦达到超时时间仍没有释放,则自动释放锁。 Redis 的 setex 命令实现了这一功能。

SETEX key seconds value

它相当于:

SET key value EXPIRE key seconds

6. 方案3 — setnx

上述方法利用锁失效时间 TTL 机制实现了死锁的避免,但这里又有一个新的问题,失效时间设置多大合适呢? 显然,失效时间不能设置过短,否则会出现持有锁的进程尚未完成执行,锁已经被释放,另一个进程获取到锁也同时进入竞争条件,锁失去了他存在的意义,因此生产环境中,锁的 TTL 时间通常会是一个比较长的时间。 但如果 TTL 时间设置过长,一旦死锁发生,将会有很长一段时间没有任何进程能够获取到锁,这也并不是我们想要看到的。 为了进一步优化,针对上述第一种可能造成死锁的场景,我们可以通过 setnx 来实现。

SETNX key value

setnx 实现了通常用来实现锁的一个同步原语 — 比较并交换。 如果设置的 key 不存在,则创建 key 并存入值。 这样,我们可以通过只有 key 不存在时的首个设置的进程可以设置成功这一机制来实现锁,通过 delete 操作来实现锁的释放。 这样的好处在于,只要我们每个分布式进程都拥有一个自己唯一的标识符,并把他作为 setnx 的 value 传入,如果调用 setnx 方法超时,则进行一次 get 操作,比较返回的值与自身持有的标识符是否相同,就可以清楚的得到加锁的进程,从而避免由于调用超时造成的不确定性。

7. 方案3改进 — setnx + expire

上面的方案完美解决了两个可能造成死锁的场景中的第一个,但对于第二种场景来说,仍然是无能为力的,我们依然要依赖锁 TTL 时间来解决这个问题。 Redis 提供了 expire 方法来实现 TTL 时间的指定,我们可以通过 setnx 获取到锁之后调用 expire 命令来实现对锁的超时时间的指定。

SETNX key value EXPIRE key seconds

8. 终极方案 — redis + lua

上面的方案既通过 setnx + get 实现了两种死锁场景中的第一种场景的避免,又通过设置 TTL 时间实现了第二种场景下死锁的发生,但实际上,他仍然存在一个严重的问题,那就是 setnx 操作与 expire 操作是非原子性的,一旦 setnx 获取锁成功但在 expire 调用前崩溃,或 expire 调用失败,都会造成死锁,第二种死锁的场景仍然存在。 那么,有没有办法将 setnx 与 expire 操作合并成一个原子操作呢? 这当然是可以解决的,此前我们介绍过 Redis 事务与 LUA 脚本的编写

我们知道,Redis 事务仅仅是将两个命令进行简单的包装,仍然无法实现其调用的原子性,但通过 LUA 脚本调用则不同,LUA 脚本本身将被视为一个完整的原子性操作来运行,从而实现两步操作的合并。

8.1. 示例

代码语言:javascript
复制
import redis

if __name__ == '__main__':
    rediscli = redis.Redis(host='127.0.0.1', port=6379,
                           password='passwd')
    lua_script = """
    local ret = redis.call("setnx", KEYS[1], KEYS[2])
    if(ret == 0) then
        return 0
    end
    return redis.call("expire", KEYS[1], KEYS[3]) + 1
    """
    scriptobj = rediscli.register_script(lua_script)
    print(scriptobj(keys=['hello', 'world', 20]))

可以看到调用返回 2,同时 key hello,value world 被设置,直到 20 秒后,key 被自动删除。

9. 实例 — python 编写的 Lock 类

下面我们用 python 实现一个可靠的分布式事务锁类:

代码语言:javascript
复制
import logging
import signal
import uuid

from redis import Redis

class TechlogLock:
    def __init__(self, rediscli, expiretime=5):
        self._redis: Redis = rediscli
        self._block = True
        self._lockkey = 'techloglock'
        self._expiretime = expiretime

    def acquire(self, block=True, timeout=None):
        self._block = block
        if timeout is not None and timeout > 0:
            signal.signal(signal.SIGALRM, self.alarmhandler)
            signal.setitimer(signal.ITIMER_REAL, timeout)
        randid = str(uuid.uuid1())
        res = 0
        while True:
            try:
                res = self.setnxttl(self._lockkey, randid, self._expiretime)
            except Exception as e:
                logging.error('techloglock acquire redis exception: %s' % e)
                redisrandid = self._redis.get(self._lockkey)
                redisrandid = str(redisrandid, encoding='utf-8')
                if redisrandid == randid:
                    return True
            if res == 2:
                logging.info('techloglock acquire success')
                return True
            if res == 1:
                logging.info('techloglock acquire success but expire error, so delete and fail')
                self._redis.delete(self._lockkey)
            if not block:
                logging.info('techloglock acquire fail but timeout or not block')
                break
            logging.info('techloglock acquire fail and block')
        return False

    def release(self):
        while True:
            try:
                logging.info('techloglock try to release')
                return self._redis.delete(self._lockkey)
            except Exception as e:
                logging.info('techloglock release exception: %s' % e)
                raise LockReleaseError(e)

    def alarmhandler(self, signum, frame):
        logging.info('techloglock acquire timeout')
        self._block = False

    def __enter__(self):
        result = self.acquire()
        if not result:
            raise LockAcquireError('techloglock acquire error')

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

    def setnxttl(self, key, value, ttl):
        """
        setnx 并设置 TTL 时间
        :param key:
        :param value:
        :param ttl:
        :return: 0. 加锁失败,1. 加锁成功,设置 TTL 失败, 2. 加锁成功,设置 TTL 成功
        """
        lua_script = """
        local ret = redis.call("setnx", KEYS[1], KEYS[2])
        if(ret == 0) then
            return 0
        end
        return redis.call("expire", KEYS[1], KEYS[3]) + 1
        """
        scriptobj = self._redis.register_script(lua_script)
        return scriptobj(keys=[key, value, ttl])

class LockAcquireError(Exception): ...

class LockReleaseError(Exception): ...

9.1. 上下文管理协议

在这个分布式锁类中,我们实现了 python 上下文管理协议,如果你只需要默认的阻塞式无限超时锁,那么你只需要:

代码语言:javascript
复制
with TechlogLock(rediscli): ...

9.2. 超时控制

如果是阻塞式(block 参数为 True)并且不是无限超时(timeout 参数不为 None 且大于 0),那么,我们通过信号机制实现了超时的控制。 通过预设方法 alarmhandler 处理了 SIGALRM 信号,将锁置为非阻塞从而让下次尝试失败后直接退出,而 setitimer 方法则设置超时时间后触发 SIGALRM 信号。 这部分我们此前进行了非常详细的讲解: python 进程间通信(二) — 定时信号 SIGALRM

10. 后记 — redis 2.6.12 版本的新增特性

redis 2.6.12 版本后对 set 方法进行了修改,引入了一系列可选参数:

SET key value [expiration EX seconds|PX milliseconds] [NX|XX]

这一新特性让 set 命令可以替代 SETNX、SETEX、PSETEX 等一系列命令,同时,原子性的保证让我们可以大幅降低加锁原语的复杂度。 如果你是用的 redis 版本大于等于 2.6.12,你可以用下面的方法替代上面的 lua 脚本:

代码语言:javascript
复制
import redis

if __name__ == '__main__':
    rediscli = redis.Redis(host='127.0.0.1', port=6379,
                           password='passwd')
    rediscli.set('hello', 'world', ex=20, nx=True)

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-05-31,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 小脑斧科技博客 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 引言
  • 2. Redis 实现分布式锁的基本思想
  • 3. 方案1 — set
  • 4. 方案2 — incrby
  • 5. 方案1改进 — setex
  • 6. 方案3 — setnx
  • 7. 方案3改进 — setnx + expire
  • 8. 终极方案 — redis + lua
    • 8.1. 示例
    • 9. 实例 — python 编写的 Lock 类
      • 9.1. 上下文管理协议
        • 9.2. 超时控制
        • 10. 后记 — redis 2.6.12 版本的新增特性
        相关产品与服务
        云数据库 Redis
        腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档