1. 原始版本
本文将利用减库存这一常见业务的递进实现,来介绍为何需要分布式锁,以及基于redis的分布式锁是如何一步一步完善的。
首先做一下设定:
假定我们将商品A(product_id=‘A’)的库存保存在redis中,并对外提供减库存接口。(限制redis中的库存不能执行原子减操作)
将商品A的初始库存设置为200
原始版本:
from flask import Flask
from flask_redis import FlaskRedis
app = Flask(__name__)
app.config['REDIS_URL'] = 'redis://:password@host:port/db'
redis = FlaskRedis(app)
product_id = 'A'
redis.set(product_id, 200) # 设置商品的初始库存
def get_stock():
return int(redis.get(product_id).decode())
def set_stock(stock):
redis.set(product_id, stock)
@app.route("/reduce_stock")
def reduce():
"""此版本代码在客户端并发请求的情况下会出现线程安全问题。"""
stock = get_stock()
if stock > 0:
print(f"当前库存为:{stock}")
set_stock(stock - 1)
else:
print("库存不足")
return "success"
if __name__ == '__main__':
app.run(debug=True)
此版本代码在客户端并发请求的情况下会出现线程安全问题
2. 线程锁版本
import threading
lock = threading.RLock() # 使用重入锁
@app.route("/reduce_stock2")
def reduce2():
"""
此版本代码解决了线程安全性的问题,但是当应用以多进程方式运行或是运行在分布式环境时
还是会出现不同客户端读到相同库存的情况.
"""
with lock:
stock = get_stock()
if stock > 0:
print(f"当前库存为:{stock}")
redis.set(product_id, stock - 1)
else:
print("库存不足")
return "success"
3. redis分布式锁——基础版本
@app.route("/reduce_stock3")
def reduce3():
"""
redis分布式锁
redis是以单线程的方式执行客户端的并发请求的,所以此版本代码利用了redis实现了分布式锁。
"""
# setnx方法在键不存在时返回True,存在时返回False
on_lock = redis.setnx("A_lock", "locked")
if on_lock:
try:
stock = get_stock()
if stock > 0:
print(f"当前库存是:{stock}")
set_stock(stock - 1)
else:
print("库存不足")
finally:
redis.delete("A_lock")
return "success"
redis是以单线程的方式执行客户端的并发请求的,所以此版本代码利用了redis实现了分布式锁。
setnx在键不存在时返回True,存在时返回False。
每次在减库存操作前获取到锁,在减库存操作之后释放锁,粗略看来没有问题,但考虑这样的场景,在某一个客户端获取到锁还没来得及释放时,服务器宕机,锁就无法再被释放,从而造成死锁。
下面我们来解决这个死锁问题。
4. redis分布式锁——带锁过期的版本
@app.route("/reduce_stock4")
def reduce4():
"""
redis分布式锁-带过期的版本
"""
# setnx方法在键不存在时返回True,存在时返回False
on_lock = redis.setnx("A_lock", "locked")
if on_lock:
redis.expire("A_lock", 30)
try:
stock = get_stock()
if stock > 0:
print(f"当前库存是:{stock}")
set_stock(stock - 1)
else:
print("库存不足")
finally:
redis.delete("A_lock")
return "success"
上面版本的代码中,在锁获取成功后再为锁设置存活时长,这样的实现是有问题的,如获取到锁而还没来得及设置存活时间,此时服务器宕机,依然会出现死锁。所以我们需要将获取锁
和为锁设置存活时长
两个操作作为一个原子操作
def setnx_with_ttl(key, value, expires=30):
return redis.set(key, value, ex=expires, nx=True)
@app.route("/reduce_stock5")
def reduce5():
"""
redis分布式锁-带过期的版本-同时解决服务器宕机产生的死锁问题
"""
# setnx方法在键不存在时返回True,存在时返回False
on_lock = setnx_with_ttl("A_lock", "locked")
if on_lock:
try:
stock = get_stock()
if stock > 0:
print(f"当前库存是:{stock}")
set_stock(stock - 1)
else:
print("库存不足")
finally:
redis.delete("A_lock")
return "success"
此版本代码解决了死锁问题,但依然存在问题。比如原本30秒以内可以完成的减库存操作,当并发量很高时,突然客户端A要31秒才能完成。此时就出现这样的现象:
在30秒时,客户端A的锁被自动释放了
此时客户端B成功获取到锁,并开始执行。
客户端A在31秒减库存操作完成后,进行锁的释放。(实际释放的是客户端B的锁)
后面的客户端请求会不断出现这样的交替
这样的现象中包含两个问题:
锁的过期时间小于业务需要运行的时间
锁被非当前客户端误删
下面我们我们首先来解决“锁被误删”的问题。
5. redis分布式锁——带锁唯一标识的版本
import uuid
def release_lock(key, value):
if redis.get(key).decode() == value:
redis.delete(key)
@app.route("/reduce_stock6")
def reduce6():
"""
redis分布式锁-带锁唯一标识的版本
"""
uid = str(uuid.uuid4())
# setnx方法在键不存在时返回True,存在时返回False
on_lock = setnx_with_ttl("A_lock", uid)
if on_lock:
try:
stock = get_stock()
if stock > 0:
print(f"当前库存是:{stock}")
set_stock(stock - 1)
else:
print("库存不足")
finally:
# 其中锁的释放会涉及三个操作:获取锁值、判断和删除锁
release_lock("A_lock", uid)
return "success"
此版本代码通过将每个锁的值设置为一个唯一值
,来标识不同的锁,解决了“锁被误删”的问题。其中锁的释放会涉及三个操作:获取锁值
、判断
和删除锁
,所以代码中利用了lua脚本将这三个操作变为原子操作
。
接下来解决“锁的过期时间小于业务需要运行时间”的问题。
6. redis分布式锁——带锁延期的版本
import uuid
import time
import threading
from flask import Flask
from flask_redis import FlaskRedis
app = Flask(__name__)
app.config['REDIS_URL'] = 'redis://:'
redis = FlaskRedis(app)
product_id = 'A'
redis.set(product_id, 200) # 设置商品的初始库存
def get_stock():
"""获取库存"""
return int(redis.get(product_id).decode())
def set_stock(stock):
"""设置库存"""
redis.set(product_id, stock)
def setnx_with_ttl(key, value, expires=30):
"""设置锁的过期时间、以及能否拿到锁的状态"""
return redis.set(key, value, ex=expires, nx=True)
def release_lock(key, value):
"""获取锁、判断、释放锁"""
if redis.get(key).decode() == value:
redis.delete(key)
def set_with_ttl(key, value, expires=30):
"""设置锁的过期时间"""
redis.set(key, value, ex=expires)
def lock_renewal(lock_key, lock_value, expires=30):
"""开启一个子线程对锁进行延期"""
while True:
rv = redis.get(lock_key)
if rv is not None and rv.decode() == lock_value:
print('执行锁延期...')
set_with_ttl(lock_key, lock_value, expires) # 将锁的过期时间重新设置为30秒
else:
break
time.sleep(expires // 3)
def acquire_lock(lock_key, lock_value, expires=30):
"""设置锁"""
on_lock = setnx_with_ttl(lock_key, lock_value, expires)
if on_lock:
threading.Thread(target=lock_renewal, args=(lock_key, lock_value, expires)).start()
return on_lock
@app.route('/reducestock')
def reduce():
uid = str(uuid.uuid4())
on_lock = acquire_lock('product_lock', uid, 30)
if on_lock:
try:
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
time.sleep(40)
finally:
release_lock('product_lock', uid)
return 'success'
此版本代码,在每次锁获取成功后,新开一个线程定时将对应锁的过期时间延后,从而解决了“锁的过期时间小于业务需要运行时间”的问题。
总结
到这里基本已经实现了一个可用于生产环境的redis分布式锁,但依旧还是存着如下不足之处:
分布式锁降低了应用的并发性能
redis的主从复制架构是ap型(对应cap定理),锁被写入到主节点时就返回客户端,若锁还未同步到从节点时,主节点挂机,依然会出现锁丢失的问题。
对于第一点不足,可以利用锁分段的方法进行优化,比如库存问题,我们可以将一个商品拆分为几个来减小锁的粒度,从而提升性能。
对于第二点不足,有两种解决方案:①改用zookeeper实现分布式锁(主从复制架构是cp型);②Redlock实现分布式锁(本质也是cp型)。但是这两种方案都会降低并发性能,所以如何选择还是要看业务需求。