在高并发的分布式场景中,实现位于不同节点的线程对代码和资源的同步访问,保证分布式场景下处理共享数据的安全性(如防止库存超卖),就需要用到分布式锁技术。分布式锁是控制分布式系统中的各节点或不同系统之间共同访问共享资源的一种锁的实现,如果位于不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰保证数据的一致性。分布式锁的实现方式有很多种,如通过Zookeeper、Redis、MySQL、Memcache实现,在本文主要讨论如何通过Redis来实现分布式锁。
在Python中使用多线程可以实现并发需求,由于线程之间是共享数据的,当多个线程同时操作共享数据时,为保证此共享数据在同一时间只能由一个线程访问,可以通过“加锁”来实现,否则将会出现数据错乱,使程序的运行结果变得不可预期,此现象称之为“线程不安全”。在多线程编程中,数据共享是最常见的问题之一,当多个线程同时修改某一个共享数据时,需要进行同步控制。线程同步能保证多个线程安全的访问竞争资源,最常用的线程同步机制是引用"互斥锁"。互斥锁为共享资源指定了两种状态,“锁定"与"非锁定”。当某一线程要更改共享数据时,对其"加锁",被锁的共享资源是不可以被其他线程修改的,直到加锁线程通过“解锁”释放资源,将共享资源的状态变成"非锁定",其他线程才能再次锁定该资源。互斥锁保证了每次只有一个线程能操作共享资源,从而保证了多线程场景下操作共享数据的安全性与共享数据结果的准确性。示意图如下所示:
#!/usr/bin/python
#coding:utf-8
"""
代码运行环境python2.7.5
"""
import threading
var_without_lock = 0
count = 10000
def increment_without_lock():
global var_without_lock
for _ in range(count):
var_without_lock += 1
def decrement_without_lock():
global var_without_lock
for _ in range(count):
var_without_lock -= 1
if __name__ == '__main__':
t1 = threading.Thread(target=increment_without_lock)
t2 = threading.Thread(target=decrement_without_lock)
for j in [t1, t2]:
j.start()
for j in [t1, t2]:
j.join()
print('without lock value: %s' %var_without_lock)
多次执行代码,观察代码输出,可见代码执行结果错误且执行结果是非幂等性的。代码输出结果如下所示:
[root@localhost python]# python demo5.py
without lock value: 0
[root@localhost python]# python demo5.py
without lock value: 1240
[root@localhost python]# python demo5.py
without lock value: -344
[root@localhost python]# python demo5.py
without lock value: 0
[root@localhost python]# python demo5.py
without lock value: 0
[root@localhost python]# python demo5.py
without lock value: 668
通过引入互斥锁保证线程安全与共享数据的准确,在单进程的并发场景,可以使用编程语言相应类库提供的锁,如Java中的synchronized或Python threading中的threading.Locker()。示例代码如下:
#!/usr/bin/python
#coding:utf-8
import threading
var_with_lock = 0
count = 10000
lock = threading.Lock()
def increment_with_lock():
global var_with_lock
lock.acquire()
for _ in range(count):
var_with_lock += 1
lock.release()
def decrement_with_lock():
global var_with_lock
lock.acquire()
for _ in range(count):
var_with_lock -= 1
lock.release()
if __name__ == '__main__':
t1 = threading.Thread(target=increment_with_lock)
t2 = threading.Thread(target=decrement_with_lock)
for j in [t1, t2]:
j.start()
for j in [t1, t2]:
j.join()
print('with lock value: %s' %var_with_lock)
多次执行代码,观察代码输出,可见代码执行结果是正确的且是幂等性的。
[root@localhost python]# python demo6.py
with lock value: 0
[root@localhost python]# python demo6.py
with lock value: 0
[root@localhost python]# python demo6.py
with lock value: 0
[root@localhost python]# python demo6.py
with lock value: 0
[root@localhost python]# python demo6.py
with lock value: 0
[root@localhost python]# python demo6.py
with lock value: 0
[root@localhost python]# python demo6.py
with lock value: 0
在高并发的分布式场景中,实现位于不同节点的线程对代码和资源的同步访问,保证分布式场景下处理共享数据的安全性(如防止库存超卖),就需要用到分布式锁技术。示意图如下所示:
分布式锁是控制分布式系统或不同系统之间共同访问共享资源的这一种锁实现,如果位于不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰保证数据的一致性。分布式锁的实现方式有很多种,如通过Zookeeper、Redis、MySQL、Memcache实现,在本文主要讨论如何通过Redis来实现分布式锁。
2.1
分布式锁的特征
一个相对安全的分布式锁,通常具备如下几点特征:
- 互斥性:互斥是锁机制的基本特征,即同一时刻锁只能被一个线程所持有
- 超时释放: 为锁设置超时时间,可以有效避免死锁
- 可重入性: 一个线程在持有锁的情况下,可通过对其再次加锁,防止在锁的生命到期时线程任务还未执行完,而出现锁的释放的问题
- 高性能与高可用: 加锁与解锁过程的性能开销要尽可能低,同时也要通过高可用的方式防止分布式锁意外失效
3
单节点redis实现分布式锁
3.1
加锁
通过redis实现分布式锁,主要利用redis的setnx命令进行加锁操作,该redis命令的特点为:
- 如果key不存在, 则value设置成功
- 如果key已存在,则value设置失败
当线程执行setnx返回值为1,说明key原本不存在,该线程成功加锁;当setnx返回0,说明key已存在,当前线程抢锁失败。
172.16.70.143:6379> setnx key 2
(integer) 1
172.16.70.143:6379> setnx key 3
(integer) 0
172.16.70.143:6379> setnx key 4
(integer) 0
3.3
锁超时
如果一个得到锁的线程在执行任务的过程中宕掉且未来得及调用del释放锁,那么这块共享资源将会被永久锁定,其他系统无法使用,这种情况称之为死锁。所以在通过setnx加锁后必须对其设置一个超时时间,以保证即使锁没有被显式释放,也会在一定的时间后自动释放。在redis中可以使用expire设置key的超时时间,实现锁的超时释放机制。
172.16.70.143:6379> expire key 2
4
redis分布式锁的Python实现
4.1
第一个分布式锁的代码案例
了解了通过redis实现分布式锁的方式以及分布式锁应具备的特征,第一版通过Python实现的分布式锁,代码如下所示:
if r.setnx("tom", os.getpid()):
r.expire("tom", 60)
s = "分布式锁获取成功, 当前加锁进程为%s"%os.getpid()
print(s)
try:
time.sleep(2)
print(s)
except:
traceback.print_exc()
finally:
r.delete("tom")
#注r为redis连接对象,本例中的redis连接实例化的过程略.
以上的分布式锁的实现方式不可以应用在生产环境中,因为其存在如下几点致命问题。
4.2
setnx与expire的非原子性问题
上述代码中redis连接对象r通过与redis服务器进行了2次交互,才通过setnx与expire设置了key及其超时时间。但如果setnx执行成功,却在设置expire的过程中由于redis服务器故障或网络丢包等问题,导致expire命令没有执行,那此时锁会由于没设置超时时间而变成死锁。示意图如
为了解决该问题,必须将setnx与expire这两个过程合并为一个原子性的过程,可喜的是在较新版本的redis中可以通过set命令并传入ex、nx等参数实现加锁与设置超时时间的原子性。示例代码如下:
set(name, value, ex=None, px=None, nx=False, xx=False)
ex - 过期时间(秒)
px - 过期时间(毫秒)
nx - 如果设置为True,则只有name不存在时,当前set操作才执行
xx - 如果设置为True,则只有name存在时,当前set操作才执行
if r.set("hello", os.getpid(), ex=10, nx=True):
s = "分布式锁获取成功, 当前加锁进程为%s"%os.getpid()
print(s)
try:
time.sleep(2)
print(s)
except:
traceback.print_exc()
finally:
r.delete("hello")
4.3
超时解锁问题
4.3.1
解锁错误问题
如果节点A获取到了锁,并设置了锁定时间为10S,但是节点A代码的执行时间超过了10S,锁过期则自动释放;此时节点B获取到了锁,随后节点A任务执行完成,节点A使用DEL删除锁,但此时节点B的任务还未执行完成,节点A此时释放的是节点B加的锁。流程图如下所示:
通过以上流程图梳理出以下需求以及代码实现:
- 分布式任务节点1与节点2上修改公共数据的函数为f1、f2
- 节点1上的任务函数f1先获取到了分布式锁,锁的生命周期为10秒
- 节点1上的任务函数f1运行8S后,节点2上的任务函数f2开始执行,并尝试获取分布式锁
- 节点2获取分布式锁失败,此时分布式锁的加锁者是节点1且该锁并未超时
- 节点1加的分布式锁在10秒钟后到期,自动解锁,此时节点A的任务并未执行完毕
- 节点2尝试获取分布式锁,加锁成功,锁的生命周期为10S
- 节点1任务函数执行15秒执行完毕,尝试解锁,并解锁成功;此时节点1释放掉了节点2加的锁(严重问题)
# Redis分布式锁代码:
class RedisLock:
def __init__(self, redis_conn):
self.redis_conn = redis_conn
self.lock_key = "redis_lock"
def lock(self, expire):
lock_expire_time = expire # 锁的超时时间
lock_acquire_time = 10 # 获取锁的时间
start = time.time() ## 当前时间
while 1:
t_id = threading.currentThread().ident
print("时间%s, 线程%s尝试获取锁.."%(int(time.time()), t_id))
locked = self.redis_conn.set(self.lock_key, t_id, ex=lock_expire_time, nx=True)
if locked:
print("时间%s, 线程%s获取锁成功.." %(int(time.time()), t_id))
return True
else:
time.sleep(2)
now = time.time()
time_range = now - start
print("时间%s..线程%s获取锁失败..." % (int(time.time()), t_id))
if time_range >= lock_acquire_time:
return False
def unlock(self):
value = self.redis_conn.get(self.lock_key)
s = "时间%s, 线程%s解锁成功. 加锁人是%s" %(int(time.time()), threading.currentThread().ident, value)
print(s)
self.redis_conn.delete(self.lock_key) ##解锁..
任务节点1:
def f1(r):
r.lock(10)
time.sleep(15)
global PUBLIC
PUBLIC += 1
print("时间%s, 节点1函数f1执行完成..准备解锁.." %(int(time.time())))
r.unlock()
任务节点2:
def f2(r):
time.sleep(8)
r.lock(10)
time.sleep(6)
global PUBLIC
PUBLIC += 1
print("时间%s, 节点2函数f2执行完成..准备解锁.." %(int(time.time())))
r.unlock()
# 代码执行结果
时间1619315198, 线程123145444052992尝试获取锁..
时间1619315198, 线程123145444052992获取锁成功.. ##节点1成功加锁
时间1619315206, 线程123145449308160尝试获取锁..
时间1619315208..线程123145449308160获取锁失败...
时间1619315208, 线程123145449308160尝试获取锁..
时间1619315210..线程123145449308160获取锁失败... ##节点2尝试获取锁
时间1619315210, 线程123145449308160尝试获取锁..
时间1619315210, 线程123145449308160获取锁成功.. ## 节点2获取到锁,此时节点1的任务尚未执行完毕
时间1619315213, 节点1函数f1执行完成..准备解锁..
时间1619315213, 线程123145444052992解锁成功. 加锁人是b'123145449308160'
### 节点1任务执行完毕开始解锁,但此处节点1解的是节点2加的锁,此处就有很大问题了。
时间1619315222, 节点2函数f2执行完成..准备解锁..
时间1619315222, 线程123145449308160解锁成功. 加锁人是None # 节点2任务f2执行完成后释放锁时,锁已经被节点1释放了
为了避免以上问题,实现每个节点只能解自己加的锁的目的,可以在释放锁之前做一个判断,验证当前锁是不是自己加的锁,将解锁代码进行修改如下所示:
def unlock(self):
t_id = str(threading.currentThread().ident)
d_locker = self.redis_conn.get(self.lock_key)
if not d_locker:
print("锁不存在..")
else:
if t_id == d_locker.decode("utf-8"):
print("解锁成功, 解锁人: %s 加锁人: %s" %(t_id, d_locker))
self.redis_conn.delete(self.lock_key) ##解锁..
else:
print("加锁人%s与解锁人%s不匹配.." % (d_locker, t_id))
但是以上解决方案隐含了一个新的问题,那就是判断和释放锁是两个独立的操作,不具有原子性。所以可以通过引入Lua脚本的形式解决两条命令的原子性问题。在redis内部有一个Lua解释器,将lua脚本发送给Redis执行, redis会以单线程执行lua脚本,这种情况下lua脚本中的所有代码都会被当成一个原子性指令来执行。根据以上思路,继续优化unlock代码,如下所示:
def unlock(self):
lua = """
if redis.call('get', KEYS[1]) == ARGV[1]
then
return redis.call('del', KEYS[1])
else
return 0
end
"""
t_id = threading.currentThread().ident
cmd = self.redis_conn.register_script(lua)
res = cmd(keys=[self.lock_key], args=[t_id, ])
if res == 1:
print("解锁成功..")
else:
print("解锁失败..")
4.3.2
超时解锁导致并发
以上我们虽然避免了节点A误删其他节点的锁的情况,但仍可能存在同一时间有两个节点同时访问共享数据的情况,仍是不完美的。流程图如下所示:
多个节点对共享数据的并发是不允许的,通常解决该问题的方式有以下两点:
-
将锁的超时时间设置的足够长,确保代码逻辑能在锁释放之前执行完成
-
为获取锁的线程增加守护线程,为将要过期但未释放的锁续期
对于方案1,锁的超时时间设置过小,锁自动超时解锁的概率就会增加,锁异常失效的概率也就会增加;而锁的超时时间设置过大,万一服务出现异常无法正常解锁,那么这种异常锁的时间也就越长。锁的超时时间只能通过经验去配置成一个可接受的值,这将会对服务平均耗时的基础上再增加一部分buffer。
对于方案2,先给锁设置了一个超时时间,再自动一个守护线程,让守护线程在一段时间后,重新去设置该锁的超时时间,达到锁续期的目的。实现本方案,需要注意以下几点:
- 和解锁的情况一致,续期前要判断锁对象是否发生改变,否则会造成无论谁持有锁,守护线程都去重新设置锁的超时时间,只有在该续期的时候才续期。
- 守护线程要在合理的时间再去重设锁的超时时间,否则会造成资源浪费。
- 如果持有锁的线程已经处理完业务了,那么守护线程也应该被销毁
完整代码如下:
import redis
import threading, redis, time, os, traceback, multiprocessing, sys
from functools import wraps
redis_config = {"ip": "172.16.70.143", "port": 6379, "db": 2, "password": ""}
class RedisConnect:
_instance_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
if not hasattr(RedisConnect, "_instance"):
with RedisConnect._instance_lock:
if not hasattr(RedisConnect, "_instance"):
RedisConnect._instance = object.__new__(cls)
return RedisConnect._instance
def __init__(self, kwargs):
self.__dict__.update(kwargs)
self.pool = redis.ConnectionPool(
host=self.ip,
port=self.port,
password=self.password,
db=self.db
)
def get_conn(self):
connect = redis.Redis(connection_pool=self.pool)
return connect
class RedisLock:
@property
def lock_key(self):
return self.__lock_key
@lock_key.setter
def lock_key(self, value):
self.__lock_key = value
@property
def redis_pool(self):
return self.__redis_pool
@redis_pool.setter
def redis_pool(self, value):
if isinstance(value, RedisConnect):
self.__redis_pool = value
else:
raise TypeError("redis_pool必须是RedisConnect类型..")
def acquire(self, expire):
t_id = str(os.getpid())
lock_expire_time = expire # 锁的超时时间
lock_acquire_time = 10 # 在这个时间内获取不到锁, 则报错.
start = time.time() ## 当前时间
conn = self.__redis_pool.get_conn()
while 1:
print("时间%s, 进程%s尝试获取锁.."%(int(time.time()), t_id))
locked = conn.set(self.__lock_key, t_id, ex=lock_expire_time, nx=True)
if locked:
print("时间%s, 进程%s获取锁成功.." %(int(time.time()), t_id))
conn.close()
return True
else:
time.sleep(2)
now = time.time()
time_range = now - start
print("时间%s..进程%s获取锁失败..." % (int(time.time()), t_id))
if time_range >= lock_acquire_time:
conn.close()
raise TypeError("获取锁超时...")
def release(self):
lua = """
if redis.call('get', KEYS[1]) == ARGV[1]
then
return redis.call('del', KEYS[1])
else
return 0
end
"""
t_id = str(os.getpid())
conn = self.__redis_pool.get_conn()
cmd = conn.register_script(lua)
u = conn.get(self.__lock_key)
res = cmd(keys=[self.__lock_key], args=[t_id, ])
if res == 1:
print("时间%s, 进程%s解锁成功.. 加锁人%s" %(int(time.time()), t_id, u))
else:
print("时间%s, 进程%s解锁失败.. 加锁人%s" %(int(time.time()), t_id, u))
class RedisRenewLock(multiprocessing.Process):
REDIS_EXPIRE_SUCCESS = 1
def __init__(self, redis_pool, key, value, lockTime, group=None, target=None, name=None, args=(), kwargs={}):
multiprocessing.Process.__init__(self, group=group, target=target, name=name, args=args, kwargs=kwargs)
self.key = key
self.value = value
self.lockTime = lockTime
self.__signal = True
self.redis_pool = redis_pool
def stop(self):
self.__signal = False
def run(self):
waitTime = self.lockTime * 2 / 3
while self.__signal:
conn = self.redis_pool.get_conn()
time.sleep(waitTime)
lua = """
if redis.call('get', KEYS[1]) == ARGV[1]
then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
"""
try:
ttl = conn.ttl(self.key)
cmd = conn.register_script(lua)
res = cmd(keys=[self.key, ], args=[self.value, self.lockTime])
if res == self.REDIS_EXPIRE_SUCCESS:
sys.stdout.write("时间%s, 进程%s续期成功..续命前:%s\n" % (int(time.time()), self.value, ttl))
else:
value = conn.get(self.key)
sys.stdout.write("加锁人%s 续期人: %s 加锁跟续期不是同一个进程..\n" % (value, self.value))
self.stop()
except Exception as e:
traceback.print_exc()
s = "锁续期失败.. err:%s\n" % str(e)
sys.stdout.write(s)
finally:
conn.close()
class DirectbuteLocker:
def __init__(self, redis_config, lockName="xyz12311", expire=10):
self.__connectPool = self.__getConnectPool(redis_config)
self.__redisLock = self.__getRedisLock(
connectPool=self.__connectPool,
lock_key=lockName
)
self.__redisRenewLock = self.__getRenewLock(
self.__connectPool, lockName, expire
)
def __getConnectPool(self, redis_config):
connectPool = RedisConnect(redis_config)
return connectPool
def __getRedisLock(self, connectPool, lock_key):
redisLock = RedisLock()
redisLock.redis_pool = connectPool
redisLock.lock_key = lock_key
return redisLock
def __getRenewLock(self, connectPool, lock_key, expire):
redis_renew_lock = RedisRenewLock(
connectPool, lock_key, os.getpid(), expire
)
return redis_renew_lock
def lock(self):
self.__redisLock.acquire(10)
self.__redisRenewLock.daemon = True
self.__redisRenewLock.start()
def unlock(self):
self.__redisRenewLock.stop()
self.__redisRenewLock.terminate()
self.__redisLock.release()
PUBLIC = 1
def f1():
print("函数f1开始执行, 进程ID:%s" %os.getpid())
lock = DirectbuteLocker(redis_config)
lock.lock()
time.sleep(12)
global PUBLIC
PUBLIC += 1
print("时间%s, 节点1函数f1执行完成..准备解锁.." %(int(time.time())))
lock.unlock()
def f2():
time.sleep(8)
print("函数f2开始执行, 进程ID:%s" % os.getpid())
lock = DirectbuteLocker(redis_config)
lock.lock()
time.sleep(19)
global PUBLIC
PUBLIC += 1
print("时间%s, 节点2函数f2执行完成..准备解锁.." %(int(time.time())))
lock.unlock()
if __name__ == '__main__':
m1 = multiprocessing.Process(target=f1)
m2 = multiprocessing.Process(target=f2)
for j in [m1, m2]:
j.start()
for j in [m1, m2]:
j.join()