从线程锁到redis分布式锁

本文将利用减库存这一常见业务的递进实现,来介绍为何需要分布式锁,以及基于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分布式锁,但依旧还是存着如下不足之处:

  1. 分布式锁降低了应用的并发性能
  2. redis的主从复制架构是ap型(对应cap定理),锁被写入到主节点时就返回客户端,若锁还未同步到从节点时,主节点挂机,依然会出现锁丢失的问题。

对于第一点不足,可以利用锁分段的方法进行优化,比如库存问题,我们可以将一个商品拆分为几个来减小锁的粒度,从而提升性能。

对于第二点不足,有两种解决方案:①改用zookeeper实现分布式锁(主从复制架构是cp型);②Redlock实现分布式锁(本质也是cp型)。但是这两种方案都会降低并发性能,所以如何选择还是要看业务需求。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值