本文将利用减库存
这一常见业务的递进实现,来介绍为何需要分布式锁,以及基于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://172.16.1.100:6379/0'
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('/reducestock')
def reduce():
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
return 'success'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
此版本代码在客户端并发请求的情况下会出现线程安全问题。
线程锁版本
...
import threading
lock = threading.RLock()
@app.route('/reducestock')
def reduce():
with lock:
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
return 'success'
...
此版本代码解决了线程安全性的问题,但是当应用以多进程
方式运行或是运行在分布式
环境时,还是会出现不同客户端读到相同库存的情况。基于这样的问题,我们引入分布式锁
。
redis分布式锁——基础版本
...
@app.route('/reducestock')
def reduce():
on_lock = redis.setnx('A_lock', 'locked')
if on_lock:
try:
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
finally:
redis.delete('A_lock')
return 'success'
...
redis
是以单线程的方式执行客户端的并发请求的,所以此版本代码利用了redis
实现了分布式锁。
setnx
在键不存在时返回True
,存在时返回False
。
每次在减库存操作前获取到锁,在减库存操作之后释放锁,粗略看来没有问题,但考虑这样的场景,在某一个客户端获取到锁还没来得及释放时,服务器宕机,锁就无法再被释放,从而造成死锁。
下面我们来解决这个死锁问题。
redis分布式锁——带锁过期的版本
...
@app.route('/reducestock')
def reduce():
on_lock = redis.setnx('A_lock', 'locked')
if on_lock:
redis.expire('product_lock', 30)
try:
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
finally:
redis.delete('A_lock')
return 'success'
...
上面版本的代码中,在锁获取成功后再为锁设置存活时长,这样的实现是有问题的,如获取到锁而还没来得及设置存活时间,此时服务器宕机,依然会出现死锁。所以我们需要将获取锁
和为锁设置存活时长
两个操作作为一个原子操作
。
...
def setnx_with_ttl(key, value, expires=30):
# 第一种实现
# 利用lua脚本在redis上原子执行的特性
lua_script = """if redis.call('setnx', KEYS[1], ARGV[2]) == 1 then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end"""
return redis.eval(lua_script, 1, key, value, expires)
def setnx_with_ttl2(key, value, expires=30):
# 第二种实现
# SET key value EX 30 NX 如果在键中设置了值,返回简单字符串回复:OK。如果值没有设置则返回 Null。
return True if redis.set(key, value, ex=expires, nx=True) else False
@app.route('/reducestock')
def reduce():
on_lock = setnx_with_ttl('A_lock', 'lock')
if on_lock:
try:
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
finally:
redis.delete('A_lock')
return 'success'
...
此版本代码解决了死锁问题,但依然存在问题。比如原本30秒以内可以完成的减库存操作,当并发量很高时,突然客户端A要31秒才能完成。此时就出现这样的现象:
- 在30秒时,客户端A的锁被自动释放了
- 此时客户端B成功获取到锁,并开始执行。
- 客户端A在31秒减库存操作完成后,进行锁的释放。(实际释放的是客户端B的锁)
- 后面的客户端请求会不断出现这样的交替
这样的现象中包含两个问题:
- 锁的过期时间小于业务需要运行的时间
- 锁被非当前客户端误删
下面我们我们首先来解决“锁被误删”的问题。
redis分布式锁——带锁唯一标识的版本
...
import uuid
def release_lock(lock_key, lock_value):
lua_script = """if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end"""
return redis.eval(lua_script, 1, lock_key, lock_value)
@app.route('/reducestock')
def reduce():
uid = str(uuid.uuid4())
on_lock = setnx_with_ttl('A_lock', uid)
if on_lock:
try:
stock = get_stock()
if stock > 0:
print('当前库存为:{}'.format(stock))
set_stock(stock - 1)
else:
print('库存不足')
finally:
release_lock('A_lock', uid)
return 'success'
...
此版本代码通过将每个锁的值设置为一个唯一值
,来标识不同的锁,解决了“锁被误删”的问题。其中锁的释放会涉及三个操作:获取锁值
、判断
和删除锁
,所以代码中利用了lua脚本将这三个操作变为原子操作
。
接下来解决“锁的过期时间小于业务需要运行时间”的问题。
redis分布式锁——带锁延期的版本
import uuid
import time
import threading
from flask import Flask
from flask_redis import FlaskRedis
app = Flask(__name__)
app.config['REDIS_URL'] = 'redis://172.16.1.100:6379/0'
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):
lua_script = """if redis.call('setnx', KEYS[1], ARGV[2]) == 1 then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end"""
return redis.eval(lua_script, 1, key, value, expires)
def release_lock(lock_key, lock_value):
lua_script = """if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end"""
return redis.eval(lua_script, 1, lock_key, lock_value)
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('执行锁延期...')
setnx_with_ttl(lock_key, lock_value, expires)
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'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
此版本代码,在每次锁获取成功后,新开一个线程定时将对应锁的过期时间延后,从而解决了“锁的过期时间小于业务需要运行时间”的问题。
总结
到这里基本已经实现了一个可用于生产环境的redis分布式锁,但依旧还是存着如下不足之处:
- 分布式锁降低了应用的并发性能
- redis的主从复制架构是ap型(对应cap定理),锁被写入到主节点时就返回客户端,若锁还未同步到从节点时,主节点挂机,依然会出现锁丢失的问题。
对于第一点不足,可以利用锁分段
的方法进行优化,比如库存问题,我们可以将一个商品拆分为几个来减小锁的粒度,从而提升性能。
对于第二点不足,有两种解决方案:①改用zookeeper实现分布式锁(主从复制架构是cp型);②Redlock实现分布式锁(本质也是cp型)。但是这两种方案都会降低并发性能,所以如何选择还是要看业务需求。