通过 redis + lua 实现分布式事务锁

2019-05-31 10:19:39   最后更新: 2019-05-31 10:19:39   访问数量:119




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

分布式事务通用解决方案

 

但是,无论是哪种分布式事务解决方案,都不可缺少的需要分布式事务锁在关键节点进行锁定来保证对竞争条件访问的一致性

目前最为常用的分布式事务锁解决方案有两种:通过 Redis 或 zookeeper 来实现,本文我们就来详细探讨一下通过 Redis 实现分布式事务锁的常见方案,以及每个方案所隐藏的坑,最终实现一个最为可靠与实用的分布式锁方案

 

 

Redis 能够用来实现分布式锁一来是由于 Redis 本身的分布式缓存的特性,同时,Redis 作为一个单线程存储服务,其大量的命令在执行中都是单线程独占的,从而实现了命令的原子性,这正是锁最重要的特性和基础

只要多个分布式进程同时设置锁状态,最终只有一个进程能够获取到加锁成功的状态,这就是一个初步可用的分布式锁

那么,如何来实现呢?下面我们就来慢慢道来

 

SET key value

 

Redis 的 set 命令在保证原子性的同时,返回变更的条数

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

 

INCRBY key increment

 

上述的方案1实现了加锁与解锁,但与 set 方法相比,incrby 则可以实现的更为强大的锁机制 -- 信号量

我们可以预设一个值,并通过 incrby 传入 -1 来实现加锁操作,返回大于等于 0 则表示加锁成功,否则表示失败,失败后需再次调用 incrby 传入 1 来恢复此次加锁失败造成的影响

 

上面方案1和方案2乍看起来完全可以实现一个分布式事务锁,但他存在一个问题 -- 一旦本应获取到锁的进程调用超时,他无法获取到加锁状态,安全起见,他不能假设自己已经获取到锁,因此重新获取锁,但此时锁已是加锁状态,由于没有任何进程实际持有锁,所有进程均无法再获取到锁,全部处于等待状态,从而陷入了死锁

即便上述情况没有发生,获取到锁之后的进程仍然是有可能在解锁前崩溃的,这就造成了死锁

如何解决上述两种场景下的死锁呢?最简单的方法就是在锁上加一个超时,一旦达到超时时间仍没有释放,则自动释放锁

Redis 的 setex 命令实现了这一功能

SETEX key seconds value

 

它相当于:

SET key value

EXPIRE key seconds

 

上述方法利用锁失效时间 TTL 机制实现了死锁的避免,但这里又有一个新的问题,失效时间设置多大合适呢?

显然,失效时间不能设置过短,否则会出现持有锁的进程尚未完成执行,锁已经被释放,另一个进程获取到锁也同时进入竞争条件,锁失去了他存在的意义,因此生产环境中,锁的 TTL 时间通常会是一个比较长的时间

但如果 TTL 时间设置过长,一旦死锁发生,将会有很长一段时间没有任何进程能够获取到锁,这也并不是我们想要看到的

为了进一步优化,针对上述第一种可能造成死锁的场景,我们可以通过 setnx 来实现

SETNX key value

 

setnx 实现了通常用来实现锁的一个同步原语 -- 比较并交换

如果设置的 key 不存在,则创建 key 并存入值

这样,我们可以通过只有 key 不存在时的首个设置的进程可以设置成功这一机制来实现锁,通过 delete 操作来实现锁的释放

这样的好处在于,只要我们每个分布式进程都拥有一个自己唯一的标识符,并把他作为 setnx                  的 value 传入,如果调用 setnx 方法超时,则进行一次 get 操作,比较返回的值与自身持有的标识符是否相同,就可以清楚的得到加锁的进程,从而避免由于调用超时造成的不确定性

 

上面的方案完美解决了两个可能造成死锁的场景中的第一个,但对于第二种场景来说,仍然是无能为力的,我们依然要依赖锁 TTL 时间来解决这个问题

Redis 提供了 expire 方法来实现 TTL 时间的指定,我们可以通过 setnx 获取到锁之后调用 expire 命令来实现对锁的超时时间的指定

SETNX key value

EXPIRE key seconds

 

上面的方案既通过 setnx + get 实现了两种死锁场景中的第一种场景的避免,又通过设置 TTL 时间实现了第二种场景下死锁的发生,但实际上,他仍然存在一个严重的问题,那就是 setnx 操作与 expire 操作是非原子性的,一旦 setnx 获取锁成功但在 expire 调用前崩溃,或 expire 调用失败,都会造成死锁,第二种死锁的场景仍然存在

那么,有没有办法将 setnx 与 expire 操作合并成一个原子操作呢?

这当然是可以解决的,此前我们介绍过 Redis 事务与 LUA 脚本的编写:

Redis 事务与 Redis Lua 脚本的编写

 

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

 

示例

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 被自动删除

 

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

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): ...

 

 

上下文管理协议

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

with TechlogLock(rediscli): ...

 

 

超时控制

如果是阻塞式(block 参数为 True)并且不是无限超时(timeout 参数不为 None 且大于 0),那么,我们通过信号机制实现了超时的控制

通过预设方法 alarmhandler 处理了 SIGALRM 信号,将锁置为非阻塞从而让下次尝试失败后直接退出,而 setitimer 方法则设置超时时间后触发 SIGALRM 信号

这部分我们此前进行了非常详细的讲解:

python 进程间通信(二) -- 定时信号 SIGALRM

 

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

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

 

这一新特性让 set 命令可以替代 SETNX、SETEX、PSETEX 等一系列命令,同时,原子性的保证让我们可以大幅降低加锁原语的复杂度

如果你是用的 redis 版本大于等于 2.6.12,你可以用下面的方法替代上面的 lua 脚本:

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)

 

 

欢迎关注微信公众号,以技术为主,涉及历史、人文等多领域的学习与感悟,每周三到七篇推文,全部原创,只有干货没有鸡汤

 

 






竞争条件            lua      redis      分布式      transaction      事务     


京ICP备15018585号